普通视图

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

微前端(无界)样式架构重构方案

2026年2月10日 18:24

日期: 2025-02-10
影响范围: 主应用 apps/main、共享包 packages/ui、所有子应用


一、现状诊断

1.1 样式文件分布(主应用)

当前主应用存在 3 个互不关联的样式目录,没有统一入口:

apps/main/src/
├── assets/
│   ├── main.css              ← Vue 脚手架残留 + Tailwind 入口
│   ├── base.css              ← Vue 脚手架残留 CSS Reset + 变量
│   └── styles/
│       └── micro-app.css     ← 微前端"隔离"样式(!important 硬覆盖)
├── styles/
│   ├── index.scss            ← 样式入口(⚠️ 从未被任何文件引用!)
│   ├── var.css               ← 布局 + 主题 CSS 变量
│   ├── dark.css              ← 暗色主题变量
│   ├── resizeStyle.scss      ← 632 行巨型文件(EP 覆盖 + 布局 + 业务混杂)
│   ├── dialog.scss           ← 弹窗尺寸
│   ├── theme.scss            ← 空文件(全注释)
│   ├── variables.scss        ← 仅 2 个 SCSS 变量
│   └── global.module.scss    ← 导出命名空间给 JS

1.2 main.ts 样式导入链

main.ts
  ├── assets/main.css           → @import base.css + @import tailwindcss
  ├── assets/styles/micro-app.css
  ├── element-plus 组件样式 ×4(message-box / message / notification / loading)
  └── @cmclink/ui/styles        → variables + element-override + base + utilities + animations + tailwindcss

关键发现styles/index.scss(包含 var.cssresizeStyle.scssdialog.scss在整个项目中没有被任何文件 import。这意味着 632 行的 Element Plus 覆盖样式、布局变量、暗色主题等可能完全没有生效,或者曾经生效但在某次重构中被遗漏。

1.3 核心问题清单

# 问题 严重程度 说明
1 Tailwind CSS 重复引入 🔴 高 assets/main.css@cmclink/ui/styles 各引入一次,产生重复样式
2 CSS Reset 重复执行 🔴 高 assets/base.css@cmclink/ui/base.scss 各做一次全局 reset
3 主色定义冲突 🔴 高 var.css#005aae@cmclink/ui#004889resizeStyle.scss→硬编码 #005aae,三处不一致
4 styles/index.scss 未被引用 🔴 高 632 行 EP 覆盖 + 布局变量 + 暗色主题可能完全未生效
5 body 样式双重定义 🟡 中 base.css@cmclink/ui/base.scss 都设置了 body 样式
6 微前端样式隔离 = !important 战争 🔴 高 micro-app.css!important 硬覆盖,没有利用 wujie CSS 沙箱
7 脚手架残留代码 🟡 中 base.css--vt-c-* 变量、main.css.green
8 废文件堆积 🟡 中 theme.scss(空)、variables.scss(2 行)、global.module.scss(价值存疑)
9 !important 泛滥 🟡 中 resizeStyle.scssmicro-app.css 大量使用
10 样式职责不清 🔴 高 EP 覆盖、页面布局、业务样式、主题变量全混在一个文件

1.4 @cmclink/ui 样式包分析

packages/ui/src/styles/ 已经有一套相对完整的设计体系:

  • variables.scss — 完整的设计令牌(颜色、字体、间距、圆角、阴影等)+ CSS 变量映射
  • mixins.scss — 响应式断点、文本截断、布局、按钮变体等混合器
  • base.scss — 全局 reset、标题、段落、表格、滚动条、打印、暗色、无障碍
  • element-override.scss — Element Plus 样式覆盖
  • utilities.scss — CMC 专用工具类(cmc-* 前缀)
  • animations.scss — 动画库(1250 行,含大量与 Tailwind 重复的工具类)

问题@cmclink/uianimations.scss 中有大量 .translate-*.scale-*.rotate-*.duration-* 等类名,与 Tailwind CSS 完全重复。


二、目标架构设计

2.1 设计原则

  1. 单一真相源(Single Source of Truth):设计令牌只在 @cmclink/ui 中定义一次
  2. 分层隔离:全局样式、主题变量、EP 覆盖、布局样式、业务样式严格分层
  3. 微前端天然隔离:依赖 wujie 的 CSS 沙箱(WebComponent + iframe),而非 !important
  4. Tailwind 唯一入口:整个主应用只在一个地方引入 Tailwind CSS
  5. 可维护性:每个文件职责单一,命名语义化,新人 5 分钟能理解结构

2.2 目标目录结构

apps/main/src/
├── styles/                          ← 主应用样式唯一目录
│   ├── index.scss                   ← ⭐ 唯一入口文件(main.ts 只 import 这一个)
│   ├── tailwind.css                 ← Tailwind CSS 唯一引入点
│   │
│   ├── tokens/                      ← 主应用级设计令牌(覆盖/扩展 @cmclink/ui)
│   │   ├── _variables.scss          ← SCSS 变量(供 SCSS 文件内部使用)
│   │   └── _css-variables.scss      ← CSS 自定义属性(布局变量、主应用专属变量)
│   │
│   ├── themes/                      ← 主题系统
│   │   ├── _light.scss              ← 亮色主题变量
│   │   └── _dark.scss               ← 暗色主题变量
│   │
│   ├── overrides/                   ← Element Plus 样式覆盖(主应用级)
│   │   ├── _button.scss             ← 按钮覆盖
│   │   ├── _table.scss              ← 表格覆盖
│   │   ├── _form.scss               ← 表单覆盖(filterBox 等)
│   │   ├── _dialog.scss             ← 弹窗覆盖
│   │   ├── _message-box.scss        ← 消息框覆盖
│   │   ├── _select.scss             ← 下拉框覆盖
│   │   ├── _tag.scss                ← 标签覆盖
│   │   ├── _tabs.scss               ← 标签页覆盖
│   │   └── _index.scss              ← EP 覆盖汇总入口
│   │
│   ├── layout/                      ← 布局样式
│   │   ├── _app-view.scss           ← AppViewContent / AppViewScroll
│   │   ├── _login.scss              ← 登录页样式
│   │   └── _index.scss              ← 布局汇总入口
│   │
│   ├── vendors/                     ← 第三方库样式适配
│   │   └── _nprogress.scss          ← NProgress 主题色适配
│   │
│   └── legacy/                      ← 遗留业务样式(逐步迁移到组件 scoped 中)
│       ├── _si-detail.scss          ← 出口单证详情
│       ├── _marketing.scss          ← 营销模块
│       └── _index.scss              ← 遗留样式汇总入口
│
├── assets/                          ← 仅保留静态资源
│   └── images/                      ← 图片资源
│       ├── avatar.gif
│       ├── cmc-logo.png
│       └── ...

2.3 入口文件设计

styles/index.scss(唯一入口):

// =============================================================================
// CMCLink 主应用样式入口
// 加载顺序严格按照优先级排列,请勿随意调整
// =============================================================================

// 1️⃣ Tailwind CSS(最先加载,作为基础原子类层)
@use './tailwind.css';

// 2️⃣ 主应用级设计令牌(覆盖/扩展 @cmclink/ui 的变量)
@use './tokens/css-variables';

// 3️⃣ 主题系统
@use './themes/light';
@use './themes/dark';

// 4️⃣ Element Plus 样式覆盖
@use './overrides/index';

// 5️⃣ 布局样式
@use './layout/index';

// 6️⃣ 第三方库适配
@use './vendors/nprogress';

// 7️⃣ 遗留业务样式(逐步清理)
@use './legacy/index';

main.ts(重构后):

// 样式:唯一入口
import './styles/index.scss'

// Element Plus 反馈组件样式(非按需导入的全局组件)
import 'element-plus/es/components/message-box/style/css'
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/notification/style/css'
import 'element-plus/es/components/loading/style/css'

// 组件库样式(@cmclink/ui 提供设计令牌 + 基础样式 + EP 覆盖)
import '@cmclink/ui/styles'

// 其余保持不变...

注意@cmclink/ui/styles 中的 Tailwind CSS 引入需要移除,改为只在主应用的 styles/tailwind.css 中引入一次。

2.4 样式加载顺序

┌─────────────────────────────────────────────────────────┐
│ 1. Tailwind CSS(原子类基础层)                           │
├─────────────────────────────────────────────────────────┤
│ 2. @cmclink/ui/styles                                   │
│    ├── variables.scss  → 设计令牌 + CSS 变量映射          │
│    ├── base.scss       → 全局 reset + 排版               │
│    ├── element-override.scss → 组件库级 EP 覆盖           │
│    ├── utilities.scss  → CMC 工具类                       │
│    └── animations.scss → 动画(需清理与 Tailwind 重复部分)│
├─────────────────────────────────────────────────────────┤
│ 3. Element Plus 反馈组件样式(message/notification 等)    │
├─────────────────────────────────────────────────────────┤
│ 4. 主应用 styles/index.scss                              │
│    ├── tokens/    → 主应用级变量(布局尺寸、主题色扩展)    │
│    ├── themes/    → 亮色/暗色主题                         │
│    ├── overrides/ → 主应用级 EP 覆盖                      │
│    ├── layout/    → 页面布局                              │
│    ├── vendors/   → 第三方库适配                          │
│    └── legacy/    → 遗留业务样式                          │
├─────────────────────────────────────────────────────────┤
│ 5. 组件 <style scoped>(组件级样式,天然隔离)             │
└─────────────────────────────────────────────────────────┘

三、微前端样式隔离策略

3.1 wujie CSS 沙箱机制

wujie 使用 WebComponent(shadowDOM)+ iframe 双重沙箱:

  • JS 沙箱:子应用 JS 运行在 iframe 中,天然隔离
  • CSS 沙箱:子应用 DOM 渲染在 WebComponent 的 shadowDOM 中,样式天然隔离

这意味着:

  • ✅ 子应用的样式不会泄漏到主应用
  • ✅ 主应用的样式不会侵入子应用(shadowDOM 边界)
  • ⚠️ 但是:Element Plus 的弹窗(Dialog/Drawer/MessageBox)默认挂载到 document.body,会逃逸出 shadowDOM

3.2 弹窗逃逸问题的正确解决方案

当前做法(错误):在主应用 micro-app.css 中用 !important 覆盖子应用弹窗样式。

正确做法:在子应用中配置 Element Plus 的 teleportedappend-to 属性,让弹窗挂载到子应用自身的 DOM 容器内,而非 document.body

方案 A:子应用全局配置(推荐)

在子应用的入口文件中配置 Element Plus 的全局属性:

// 子应用 main.ts
import { createApp } from 'vue'
import ElementPlus from 'element-plus'

const app = createApp(App)
app.use(ElementPlus, {
  // 弹窗不使用 teleport,保持在子应用 DOM 树内
  // 这样弹窗样式就在 shadowDOM 内,天然隔离
})

同时在子应用的弹窗组件中统一设置:

<!-- 子应用中使用 Dialog -->
<el-dialog :teleported="false">
  <!-- 内容 -->
</el-dialog>

<!-- 或者指定挂载到子应用容器 -->
<el-dialog append-to=".child-app-root">
  <!-- 内容 -->
</el-dialog>

方案 B:wujie 插件拦截(自动化)

通过 wujie 的插件系统,自动为子应用的弹窗组件注入配置:

// plugins/wujie.ts 中增加插件配置
import { setupApp } from 'wujie'

setupApp({
  name: 'child-app',
  // ... 其他配置
  plugins: [
    {
      // CSS loader:可以在子应用 CSS 加载时做处理
      cssLoader: (code: string) => code,
      // JS loader:可以在子应用 JS 加载时做处理
      jsLoader: (code: string) => code,
    }
  ]
})

方案 C:共享样式包(兜底方案)

如果确实需要主应用和子应用共享某些样式(如统一的弹窗规范),应该通过 @cmclink/ui 包来分发,而非在主应用中硬写覆盖:

packages/ui/src/styles/
├── shared/                    ← 可被子应用独立引入的共享样式
│   ├── dialog-standard.scss   ← 弹窗标准样式
│   └── form-standard.scss     ← 表单标准样式

子应用按需引入:

// 子应用 main.ts
import '@cmclink/ui/styles/shared/dialog-standard.scss'

3.3 样式隔离总结

场景 隔离机制 是否需要额外处理
子应用普通样式 wujie shadowDOM 天然隔离 ❌ 无需处理
子应用弹窗/抽屉 配置 :teleported="false" ✅ 子应用侧配置
主应用与子应用共享样式 通过 @cmclink/ui 包分发 ✅ 包级别管理
主应用全局样式 不会侵入子应用(shadowDOM) ❌ 无需处理

3.4 删除 micro-app.css

重构完成后,assets/styles/micro-app.css 应该被完全删除。其中的内容处理方式:

原内容 处理方式
.micro-app .el-dialog 覆盖 子应用配置 :teleported="false" 后不再需要
.micro-app-test.marketing 测试代码,直接删除
.test-paragraph / .test-box 测试代码,直接删除

四、主色统一方案

4.1 现状:三处主色定义

位置 主色值 说明
styles/var.css #005aae 主应用布局变量
@cmclink/ui/variables.scss #004889 组件库设计令牌
resizeStyle.scss #005aae(硬编码) 登录页等处直接写死

4.2 统一方案

@cmclink/ui 的设计令牌为唯一真相源

  1. 确认最终主色值(与设计团队对齐):

    • 如果设计稿是 #004889 → 主应用的 var.css 需要同步修改
    • 如果设计稿是 #005aae@cmclink/uivariables.scss 需要同步修改
  2. 主应用中所有硬编码的颜色值,改为引用 CSS 变量:

    // ❌ 错误
    background-color: #005aae;
    
    // ✅ 正确
    background-color: var(--el-color-primary);
    
  3. 主应用的 tokens/_css-variables.scss 只定义布局相关的变量,颜色变量全部继承 @cmclink/ui


五、文件迁移映射

5.1 需要删除的文件

文件 原因
assets/main.css 脚手架残留,Tailwind 迁移到 styles/tailwind.css
assets/base.css 脚手架残留,与 @cmclink/ui/base.scss 重复
assets/styles/micro-app.css !important 硬覆盖,改用 wujie 沙箱
styles/theme.scss 空文件
styles/variables.scss 仅 2 行,合并到 tokens/_variables.scss
styles/global.module.scss 价值存疑,如需保留可合并

5.2 需要拆分的文件

styles/var.css(154 行)→ 拆分为:

目标文件 内容
tokens/_css-variables.scss 布局变量(--left-menu-*--top-header-*--tags-view-*--app-content-* 等)
删除 颜色变量(--el-color-primary 等),改为继承 @cmclink/ui

styles/resizeStyle.scss(632 行)→ 拆分为:

目标文件 内容 行数(约)
overrides/_button.scss 按钮样式覆盖(L1-20) ~20
overrides/_tag.scss 标签样式(L29-33) ~5
overrides/_select.scss 下拉框样式(L36-38) ~3
overrides/_table.scss 表格样式(L41-80, L374-417) ~80
overrides/_message-box.scss 消息框样式(L83-98, L399-406) ~20
overrides/_form.scss filterBox 表单样式(L484-618) ~135
layout/_login.scss 登录页样式(L100-117) ~18
layout/_app-view.scss AppViewContent / AppViewScroll / tabPage(L119-306) ~190
legacy/_si-detail.scss 出口单证详情(index.scss L50-105) ~55
legacy/_marketing.scss 营销模块(L308-373, L418-438) ~70
删除 .container-selected-row(箱管选中行)→ 迁移到对应组件 scoped ~8

styles/index.scss(126 行)→ 拆分为:

目标文件 内容
vendors/_nprogress.scss NProgress 主题色适配(L22-38)
overrides/_form.scss append-click-input(L39-49)
legacy/_si-detail.scss si-detail-dialog / si-detail-tabs(L50-105)
删除 .reset-margin.el-popup-parent--hidden.el-scrollbar__bar → 评估是否仍需要

styles/dark.css(85 行)→ 迁移到:

目标文件 说明
themes/_dark.scss 完整保留暗色主题变量,清理注释掉的废代码

styles/dialog.scss(17 行)→ 迁移到:

目标文件 说明
overrides/_dialog.scss 弹窗尺寸规范

5.3 @cmclink/ui 需要调整的部分

文件 调整内容
styles/index.scss 移除 @use 'tailwindcss',Tailwind 只在应用层引入
styles/animations.scss 清理与 Tailwind 重复的工具类(.translate-*.scale-*.rotate-*.duration-* 等约 800 行)
styles/utilities.scss 保留 cmc-* 前缀的工具类,删除与 Tailwind 重复的部分

六、实施步骤

Phase 1:基础清理(低风险,可立即执行)

预计耗时:1-2 小时

  1. 创建新目录结构

    • 创建 styles/tokens/styles/themes/styles/overrides/styles/layout/styles/vendors/styles/legacy/
  2. 创建 styles/tailwind.css

    @import "tailwindcss";
    
  3. 删除废文件

    • styles/theme.scss(空文件)
  4. 统一主色

    • 与设计团队确认最终主色值
    • 更新所有硬编码颜色为 CSS 变量引用

Phase 2:文件拆分迁移(中风险,需逐步验证)

预计耗时:3-4 小时

  1. 拆分 resizeStyle.scss(632 行 → 8 个文件)
  2. 拆分 var.css(布局变量 → tokens/_css-variables.scss
  3. 迁移 dark.cssthemes/_dark.scss
  4. 迁移 dialog.scssoverrides/_dialog.scss
  5. 拆分 index.scss(NProgress → vendors,业务 → legacy)
  6. 创建新的 styles/index.scss 入口文件

Phase 3:入口重构(高风险,需完整回归测试)

预计耗时:1-2 小时

  1. 修改 main.ts

    • 移除 import './assets/main.css'
    • 移除 import './assets/styles/micro-app.css'
    • 添加 import './styles/index.scss'
    • 调整 @cmclink/ui/styles 的导入顺序
  2. 删除旧文件

    • assets/main.css
    • assets/base.css
    • assets/styles/micro-app.css
    • assets/styles/ 目录
  3. 完整回归测试

    • 登录页样式
    • 主布局(侧边栏、顶部导航、标签页)
    • 表格页面(筛选框、表格、分页)
    • 弹窗/抽屉
    • 暗色主题切换
    • 子应用加载和样式隔离

Phase 4:微前端隔离优化(需子应用配合)

预计耗时:2-3 小时(每个子应用)

  1. 子应用配置弹窗不逃逸

    • 全局配置 :teleported="false"append-to
    • @cmclink/micro-bootstrap 中提供统一配置能力
  2. 删除 micro-app.css

  3. 验证子应用样式隔离

    • 主应用样式不侵入子应用
    • 子应用样式不泄漏到主应用
    • 弹窗/抽屉样式正确

Phase 5:@cmclink/ui 优化(独立进行)

预计耗时:2-3 小时

  1. 移除 Tailwind CSS 引入(从 @cmclink/ui/styles/index.scss
  2. 清理 animations.scss 中与 Tailwind 重复的工具类(约 800 行)
  3. 审查 utilities.scss 中与 Tailwind 重复的部分

七、风险评估与回退方案

7.1 风险点

风险 概率 影响 缓解措施
styles/index.scss 未被引用但样式实际生效 先在浏览器 DevTools 确认哪些样式实际生效
拆分后样式加载顺序变化导致覆盖失效 严格按照原有顺序组织 @use
子应用弹窗 :teleported="false" 导致层级问题 逐个子应用测试,必要时用 z-index 调整
删除 base.css 后某些页面样式异常 @cmclink/ui/base.scss 已覆盖所有 reset

7.2 回退方案

每个 Phase 独立提交 Git,如果出现问题可以精确回退到任意阶段:

feat(styles): phase-1 基础清理和目录结构
feat(styles): phase-2 文件拆分迁移
feat(styles): phase-3 入口重构
feat(styles): phase-4 微前端隔离优化
feat(styles): phase-5 @cmclink/ui 优化

八、验证清单

8.1 样式正确性

  • 登录页样式正常(背景、表单、按钮)
  • 主布局样式正常(侧边栏展开/收起、顶部导航、面包屑)
  • 标签页样式正常(激活态、hover、关闭按钮)
  • 表格页面样式正常(筛选框、表头、行、分页)
  • 弹窗/抽屉样式正常(大/中/小尺寸)
  • 表单样式正常(输入框、下拉框、日期选择器)
  • 按钮样式正常(主要、链接、危险、禁用)
  • NProgress 进度条颜色正确
  • 暗色主题切换正常

8.2 微前端隔离

  • 子应用加载后样式正常
  • 主应用样式未侵入子应用
  • 子应用样式未泄漏到主应用
  • 子应用弹窗样式正确(不逃逸到主应用 body)
  • 多个子应用切换时样式无残留

8.3 构建产物

  • 构建无报错
  • CSS 产物体积不增长(预期减少 30%+)
  • 无重复的 Tailwind CSS 输出
  • 无重复的 CSS Reset

8.4 开发体验

  • HMR 样式热更新正常
  • 新增样式时知道该放在哪个文件
  • SCSS 变量和 CSS 变量可正常引用

九、预期收益

维度 现状 重构后
CSS 产物体积 Tailwind ×2 + Reset ×2 + 大量重复 减少约 30-40%
样式文件数 散落 3 个目录,8+ 个文件 1 个目录,清晰分层
入口文件 main.ts 导入 2 个样式文件 + 隐式依赖 main.ts 导入 1 个入口
新人上手 不知道样式该写在哪 5 分钟理解结构
微前端隔离 !important 硬覆盖 wujie 沙箱天然隔离
主色一致性 3 处定义,2 个不同值 1 处定义,全局统一
!important 使用 泛滥 仅在必要的 EP 覆盖中使用

React学习-setState、useState

作者 web_bee
2026年2月10日 18:20

setState

语法:

this.setState(updater, [callback])

写法总结:

// 语法:this.setState({ newState })
this.setState({ count: 1 });

// 推荐使用,尤其是状态更新依赖于前一次状态时。它可以接收当前 state 和 props
// 语法:this.setState((state, props) => { return { newState } })
this.setState((state, props) => ({
  count: state.count + 1
}));

// 在 setState 更新完成并重新渲染界面后执行,可确保获取到最新状态
this.setState({ count: 1 }, () => {
  console.log('更新后的状态:', this.state.count);
});

特性:

  • setState 是异步的:React 会将多个 setState 调用合并(batch)成一次更新,以提升性能。
  • 事件处理函数(如 onClick)中,setState 是批量的。
  • 原生事件、setTimeout、Promise 等异步上下文中,setState 可能不会自动批处理(但在 React 18+ 中已改进),可能表现为`同步状态。

⚠️ 注意:React 18 开始,无论在哪里调用 setState,默认都会自动批处理(automatic batching)。

底层原理(React 16+ Fiber 架构)

  • 调用 setState 会触发 enqueueSetState
  • React 将更新放入一个更新队列(Update Queue) ,并标记该 Fiber 节点为“需要更新”。
  • 渲染阶段(render phase) ,React 会遍历 Fiber 树,收集所有待更新的节点。
  • 提交阶段(commit phase) ,应用 DOM 更新并触发副作用(如生命周期方法)。

关键点:setState 并不立即修改 this.state,而是创建一个更新对象(update object),由 React 调度器(Scheduler)决定何时处理。

useState

它允许你向组件添加一个 状态变量,语法:

const [state, setState] = useState(initialState)

它没有回调函数;

特性:

  • setState 一样,useStatesetXXX 也是异步且批量更新的。
  • React 18 后,在任何上下文中(包括 setTimeout)都会自动批处理。

底层原理(Hooks 机制)

  • useState 是 React Hooks 的一部分,其状态存储在 Fiber 节点的 memoizedState 字段中。

  • 每个 Hook(如 useState, useEffect)在 Fiber 节点上按调用顺序形成一个链表(Hook 链表)

  • 调用 setCount 时,React 会:

    1. 创建一个更新对象(类似类组件的 update)。
    2. 将其加入对应 Hook 的更新队列。
    3. 触发组件重新渲染(schedule update)。
  • 在下一次渲染时,React 会遍历 Hook 链表,应用所有 pending updates,计算出新的 state。

📌 关键:Hooks 的状态与组件实例绑定,通过 Fiber 节点维持状态,而不是像类组件那样通过 this.state

setState vs useState 对比

特性 setState(类组件) useState(函数组件)
状态结构 单个对象(可包含多个字段) 多个独立状态(每个 useState 管理一个)
更新方式 合并对象(shallow merge) 替换整个值(非合并)
初始值 构造函数中定义 useState(initialValue)
底层存储 this.state(实际由 Fiber 管理) Fiber 的 memoizedState 链表
性能 批量更新,Fiber 调度 同左,支持并发模式
推荐使用 已逐渐被函数组件取代 React 官方推荐方式

💡 注意:useState 不会自动合并对象!

底层共通机制(React 18+)

无论是 setState 还是 useState,最终都依赖于:

  1. Fiber 架构:每个组件对应一个 Fiber 节点,状态和更新都挂载其上。
  2. 更新队列(Update Queue) :存放待处理的状态变更。
  3. 调度器(Scheduler) :基于优先级(如用户交互高优先级)决定何时执行更新。
  4. 协调(Reconciliation) :通过 diff 算法生成最小 DOM 操作。
  5. 自动批处理(Automatic Batching) :React 18 起,所有状态更新默认批处理。

踩坑小记之闭包陷阱

2026年2月10日 17:55

问题背景

在页面中,用户可以通过表格中的开关(Switch)组件快速切换计划的启用/禁用状态。系统的预期行为是:

  • 点击第一行的开关从"开启"切换到"关闭"
  • 立即点击第二行的开关从"关闭"切换到"开启"
  • 预期结果:第一行变关闭,第二行变开启

但实际发生的问题是:两行的开关状态会变成一样,要么都变成开启,要么都变成关闭。

问题现象

这个问题在刷新页面后首次操作时必现,尤其是在以下场景:

  1. 用户快速连续点击多行的状态开关
  2. 点击第一行后,在接口请求还未返回时,立即点击第二行
  3. 两个请求基本同时发出

技术实现(错误版本)

原始代码

// src/page/UpgradePlan/index.js

const [appList, setAppList] = useState([]) // 表格数据列表

/**
 * 处理计划状态变更
 * @param {object} record - 当前行数据
 * @param {string} newStatus - 新状态值 ('ACTIVE' 或 'INACTIVE')
 */
const handlePlanStatusChange = (record, newStatus) => {
  // ❌ 问题代码:基于闭包捕获的 appList
  const updatedList = appList.map(item => {
    if (item.planId === record.planId) {
      return {
        ...item,
        planStatus: newStatus,
      }
    }
    return item
  })
  setAppList(updatedList)
}

表格状态渲染

// src/page/UpgradePlan/UpgradePlanTable.js

const columns = [
  {
    title: '计划状态',
    width: 100,
    dataIndex: 'planStatus',
    fixed: 'left',
    render: (text, record) => {
      return (
        <Switch
          checked={text === 'ACTIVE' ? true : false}
          checkedChildren="开启"
          unCheckedChildren="关闭"
          loading={loadingPlanIds.has(record.planId)}
          onChange={checked => {
            handlePlanStatusChange(checked, record, text)
          }}
        />
      )
    },
  },
  // ... 其他列
]

问题根源分析

1. 闭包陷阱

这是一个经典的 React 状态闭包问题

// ❌ 每次 handlePlanStatusChange 执行时,appList 都是被闭包捕获的"旧值"
const handlePlanStatusChange = (record, newStatus) => {
  const updatedList = appList.map(item => {  // appList 来自闭包,可能已过时
    if (item.planId === record.planId) {
      return { ...item, planStatus: newStatus }
    }
    return item
  })
  setAppList(updatedList)
}

2. 执行流程演示

假设初始状态:

appList = [
  { planId: 1, planStatus: 'ACTIVE' },   // 第一行:开启
  { planId: 2, planStatus: 'INACTIVE' }  // 第二行:关闭
]

快速点击两行的执行流程:

时刻 T0: 初始 appList = [{id:1, status:'ACTIVE'}, {id:2, status:'INACTIVE'}]

时刻 T1: 用户点击第一行开关 (ACTIVE → INACTIVE)
         └─ 调用 handlePlanStatusChange(record1, 'INACTIVE')
         └─ 函数内捕获的 appList 仍是 T0 时刻的值
         └─ updatedList = [{id:1, status:'INACTIVE'}, {id:2, status:'INACTIVE'}]
         └─ setAppList(updatedList)  // 开始异步更新

时刻 T2: 用户立即点击第二行开关 (INACTIVE → ACTIVE)  [此时第一个更新还未完成]
         └─ 调用 handlePlanStatusChange(record2, 'ACTIVE')
         └─ 函数内捕获的 appList 仍是 T0 时刻的值 ⚠️ 关键问题!
         └─ updatedList = [{id:1, status:'ACTIVE'}, {id:2, status:'ACTIVE'}]
         └─ setAppList(updatedList)  // 这个更新会覆盖 T1 的更新

时刻 T3: React 处理状态更新
         └─ 第二个 setAppList 覆盖了第一个
         └─ 最终结果:[{id:1, status:'ACTIVE'}, {id:2, status:'ACTIVE'}]
         └─ ❌ 第一行的状态变更丢失了!

3. 问题本质

  • 闭包捕获过时值handlePlanStatusChange 函数体内的 appList 是在函数定义时捕获的,不是执行时的最新值
  • 异步状态更新:两个 setAppList 调用都会加入 React 的更新队列,但都基于同一时刻的旧状态快照
  • 后者覆盖前者:第二个 setAppList 执行时,会用包含过时数据的 updatedList 覆盖第一个的更新

改正措施

解决方案:使用函数式状态更新

// ✅ 改正后的代码

/**
 * 处理计划状态变更
 * @param {object} record - 当前行数据
 * @param {string} newStatus - 新状态值
 */
const handlePlanStatusChange = (record, newStatus) => {
  // ✅ 使用函数式更新,prevAppList 始终是最新的状态
  setAppList(prevAppList => {
    return prevAppList.map(item => {
      if (item.planId === record.planId) {
        return {
          ...item,
          planStatus: newStatus,
        }
      }
      return item
    })
  })
}

改正后的执行流程

时刻 T0: 初始 appList = [{id:1, status:'ACTIVE'}, {id:2, status:'INACTIVE'}]

时刻 T1: 用户点击第一行开关 (ACTIVE → INACTIVE)
         └─ 调用 setAppList(prevAppList => {...})
         └─ prevAppList = T0 时刻的值
         └─ updatedList = [{id:1, status:'INACTIVE'}, {id:2, status:'INACTIVE'}]
         └─ 加入更新队列

时刻 T2: 用户立即点击第二行开关 (INACTIVE → ACTIVE)
         └─ 调用 setAppList(prevAppList => {...})
         └─ ⚠️ 但此时 prevAppList = T1 更新后的值!
         └─ prevAppList = [{id:1, status:'INACTIVE'}, {id:2, status:'INACTIVE'}]
         └─ 只更新 id:2,保留 id:1 的状态
         └─ updatedList = [{id:1, status:'INACTIVE'}, {id:2, status:'ACTIVE'}]
         └─ 加入更新队列

时刻 T3: React 处理状态更新(批处理)
         └─ 先执行 T1 的更新
         └─ 再执行 T2 的更新(基于 T1 的结果)
         └─ 最终结果:[{id:1, status:'INACTIVE'}, {id:2, status:'ACTIVE'}]
         └─ ✅ 两行状态都正确!

关键区别对比

❌ 错误方式:直接访问闭包变量

const handlePlanStatusChange = (record, newStatus) => {
  // appList 是闭包捕获的,在快速连续调用时是同一时刻的值
  const updatedList = appList.map(...)
  setAppList(updatedList)
}

问题:

  • 多次快速调用时,所有调用都基于同一时刻的 appList
  • 后面的调用会覆盖前面的结果
  • 状态变更会丢失

✅ 正确方式:函数式状态更新

const handlePlanStatusChange = (record, newStatus) => {
  // prevAppList 参数由 React 提供,始终是最新的状态
  setAppList(prevAppList => {
    return prevAppList.map(...)
  })
}

优势:

  • React 保证每次调用时,prevAppList 都是最新的状态
  • 多次快速调用时,每次都基于前一次更新的结果
  • 状态更新会正确链式执行
  • 充分利用 React 的批处理机制

深入理解

React 状态更新的本质

React 的状态更新机制:

// React 内部维护的更新队列
const updateQueue = []

// 当调用 setAppList(newValue) 时
setAppList(newValue) 
  // React 会加入队列
  updateQueue.push({ type: 'direct', value: newValue })

// 当调用 setAppList(prevValue => newValue) 时
setAppList(prevValue => newValue)
  // React 会记录更新函数,并在需要时执行
  updateQueue.push({ type: 'function', fn: (prevValue) => newValue })

批处理时

// ❌ 直接值更新(会被覆盖)
setAppList(value1)  // → queue: [{ type: 'direct', value: value1 }]
setAppList(value2)  // → queue: [{ type: 'direct', value: value2 }] 覆盖前一个
// 最终结果:只有 value2 生效

// ✅ 函数式更新(会链式执行)
setAppList(prev => newValue1(prev))  // → queue: [fn1]
setAppList(prev => newValue2(prev))  // → queue: [fn1, fn2]
// 执行:fn1(initialState) → state1
//     fn2(state1) → state2
// 最终结果:两个更新都生效

应用场景

这个问题常见于以下场景:

  1. 列表行操作:快速切换表格中多行的状态
  2. 表单快速提交:连续提交多个表单项
  3. 购物车操作:快速添加/删除多个商品
  4. 批量操作:连续执行多个列表项的操作

总结

核心要点

项目 错误方式 正确方式
方式 setAppList(updatedList) setAppList(prev => updatedList(prev))
状态来源 闭包捕获的旧值 React 提供的最新值
快速操作 后面的调用覆盖前面的 链式执行,都生效
适用场景 单次更新 多次连续更新

最佳实践

在 React 中更新状态时,如果新状态依赖于旧状态,始终使用函数式更新:

// ✅ 推荐写法(避免闭包陷阱)
setState(prevState => {
  return {
    ...prevState,
    // 基于 prevState 的更新
  }
})

// ❌ 避免(容易踩坑)
setState({
  ...state,
  // 基于当前 state 的更新
})

参考资源


教训: 在 React 中处理依赖于前一状态的更新时,永远优先考虑使用函数式状态更新,这是避免闭包陷阱最直接有效的方法。

DeepSeek-OCR-2 开源 OCR 模型的技术

作者 A小码哥
2026年2月10日 17:14

DeepSeek-OCR-2 开源 OCR 模型的技术

OCR应用的场景和类型很广,本次使用Qwen2的架构,解决看的全(扫码方式优化)、看的的准(内容识别、视觉标记、降低重复率),多裁剪策略提取核心信息。和其他OCR模型项目还是看自己的引用场景,通用场景还是建议使用最新的模型,识别准、理解准、排版准。

2025-2026年,OCR(光学字符识别)领域迎来了开源大模型的黄金时代。继 DeepSeek 在自然语言处理领域掀起波澜之后,其于 2026 年 1 月 27 日开源的 DeepSeek-OCR-2 再次引发行业关注。几乎同期,腾讯也在 2025 年底开源了 HunyuanOCR(混元OCR)——一个仅 1B 参数却斩获多项 SOTA 的轻量级模型。

这两款模型代表了当前开源 OCR 技术的两大发展方向:DeepSeek-OCR-2 主打视觉因果流(Visual Causal Flow)的创新架构,而 HunyuanOCR 则以极致轻量化+端到端统一见长。本文将深入分析这两款模型的技术特点,并与 PaddleOCR、Qwen-VL、GOT-OCR2.0 等主流方案进行对比,帮助开发者理解各模型的适用场景。


一、DeepSeek-OCR-2:视觉因果流的革新

1.1 核心创新:DeepEncoder V2

DeepSeek-OCR-2 最引人注目的创新在于其 DeepEncoder V2 视觉编码器。传统 OCR 模型(包括大多数 VLM)采用固定的栅格扫描方式(从左到右、从上到下)处理图像,这种方式与人类阅读习惯相悖,尤其在处理复杂版面(如多栏文档、表格、图文混排)时容易产生逻辑错误。

DeepEncoder V2 引入了**视觉因果流(Visual Causal Flow)**机制:

  • 全局理解优先:模型首先建立对整页文档的全局语义理解
  • 语义驱动阅读顺序:根据内容逻辑动态确定处理顺序,而非机械扫描
  • 类人类阅读模式:能够正确处理多栏排版、表格单元格关联、图文穿插等复杂场景

技术亮点:DeepEncoder V2 采用轻量级 LLM 架构(基于 Qwen2-0.5B)替换了传统的 CLIP 视觉编码器,配合双流注意力机制——视觉 token 使用双向注意力提取全局特征,文本生成使用因果注意力保证阅读顺序合理性。

1.2 模型规格与性能

指标 DeepSeek-OCR-2
参数量 3B
视觉编码器 DeepEncoder V2 (基于 Qwen2-0.5B)
语言解码器 DeepSeek3B-MoE-A570M
支持分辨率 动态分辨率,最高 1024×1024
视觉 Token 数 256-1,120(根据内容自适应)
上下文压缩 支持,大幅降低下游 LLM 计算成本
许可证 Apache-2.0

动态分辨率配置

  • 默认方案:(0-6)×768×768 + 1×1024×1024
  • Token 数:(0-6)×144 + 256

1.3 核心能力

  1. 复杂版面解析:在表格、多栏文档、公式混排等场景表现出色
  2. Markdown/结构化输出:支持将文档直接转换为带格式的 Markdown
  3. 多语言支持:基于 DeepSeek 的多语言优势,支持主流语种
  4. 推理加速:支持 vLLM 和 Transformers 两种推理方式

DeepSeek-OCR 2让编码器学会“有逻辑地看”

二、HunyuanOCR:轻量级全能选手

2.1 端到端一体化设计

腾讯 HunyuanOCR 采用端到端训推一体架构,这是其与传统 OCR 系统的根本差异:

传统 OCR 流水线:

图像 → 版面分析 → 文本检测 → 文本识别 → 后处理 → 输出

HunyuanOCR 端到端流程:

图像 → 单次推理 → 直接输出结构化结果

这种设计彻底消除了级联误差累积问题,同时大幅简化了部署流程。

2.2 架构组成

组件 技术细节
视觉编码器 SigLIP-v2-400M,原生分辨率输入,自适应 Patching
连接器 可学习池化操作,压缩高分辨率特征保留文本密集区语义
语言模型 Hunyuan-0.5B,引入 XD-RoPE 技术解耦一维文本、二维版面、三维时空信息

XD-RoPE(扩展相对位置编码) 是 HunyuanOCR 的关键创新,它使模型能够:

  • 理解跨栏排版的逻辑关系
  • 处理跨页文档的长距离依赖
  • 保持复杂表格的行列对应关系

2.3 性能表现

评测项目 成绩 备注
OCRBench 860 分 3B 参数以下模型 SOTA
OmniDocBench 94.1 分 复杂文档解析最高分,超越 Gemini3-pro
文字检测识别 70.92% 自建基准,覆盖 9 大场景
信息抽取 92.29% 卡片/收据/视频字幕
模型大小 2GB 20GB GPU 显存可部署
支持语言 130+ 含 14 种高频小语种

三、技术对比:DeepSeek-OCR-2 vs HunyuanOCR

对比维度 DeepSeek-OCR-2 HunyuanOCR
参数规模 3B 1B
架构理念 视觉因果流,类人类阅读顺序 端到端统一,单次推理
视觉编码器 DeepEncoder V2 (LLM-based) SigLIP-v2-400M
核心创新 Visual Causal Flow 机制 XD-RoPE 位置编码
文档解析 ★★★★★ ★★★★★ (94.1分 OmniDocBench)
表格识别 强 (HTML 格式输出)
公式识别 LaTeX 格式 LaTeX 格式
多语言 主流语种 130+ 语言,含小语种翻译
部署成本 中等 低 (20GB 显存)
输出格式 Markdown、纯文本 Markdown、HTML、JSON、LaTeX
特殊能力 上下文压缩,降低下游 LLM 成本 拍照翻译、视频字幕提取
开源时间 2026-01-27 2025-11-26

3.1 关键差异解读

1. 阅读顺序理解

DeepSeek-OCR-2 的 Visual Causal Flow 在处理非线性阅读顺序的文档时具有理论优势,例如:

  • 报纸版面(多栏穿插)
  • 学术论文(图表与正文引用关系)
  • 复杂表格(跨行跨列单元格)

HunyuanOCR 则通过 XD-RoPE 在位置关系建模上达到类似效果,实测在 OmniDocBench 上取得更高分数。

2. 部署与成本

HunyuanOCR 的 1B 参数设计明显瞄准边缘部署场景,20GB 显存即可运行,适合:

  • 中小企业私有化部署
  • 移动端/嵌入式设备
  • 高并发 API 服务

DeepSeek-OCR-2 的 3B 参数提供更强的语义理解能力,适合:

  • 复杂文档的深度解析
  • 需要上下文压缩降本的大规模文档处理
  • 与 LLM 配合的多模态 RAG 系统

3. 功能覆盖

HunyuanOCR 功能更全面,内置:

  • 拍照翻译(14 种语言互译)
  • 视频字幕提取
  • 开放字段信息抽取(JSON 输出)

DeepSeek-OCR-2 更专注于文档到结构化文本的转换,强调与下游 LLM 的协同。


四、与其他主流 OCR 方案的对比

4.1 PaddleOCR:工业级成熟方案

特点 详情
定位 传统 OCR 工具库(检测+识别两阶段)
优势 生态完善、中文优化好、轻量模型多
模型大小 超轻量模型仅 8.6MB
适用场景 移动端、边缘设备、已知版式文档
局限 复杂版面需配合版面分析工具,非端到端

对比结论:PaddleOCR 适合需要精细控制低资源占用的传统 OCR 任务,而 DeepSeek-OCR-2 和 HunyuanOCR 更适合需要端到端理解复杂文档的场景。

4.2 GOT-OCR2.0:学术界的统一模型

特点 详情
定位 统一端到端 OCR-2.0 模型
架构 生成式预训练(类似 LLM)
特点 强调整体文档理解
适用场景 学术研究、复杂版式文档

对比结论:GOT-OCR2.0 与 DeepSeek-OCR-2 理念相近,但后者在视觉编码器创新和工程化(vLLM 支持)方面更进一步。

4.3 Qwen2-VL:通义千问多模态

特点 详情
定位 通用多模态大模型
参数 2B / 7B / 72B 可选
特点 视觉-语言理解能力强,不仅限于 OCR
适用场景 需要多模态理解(图像+文本+推理)的综合应用

对比结论:Qwen2-VL 是"通用选手",OCR 只是其能力之一;DeepSeek-OCR-2 和 HunyuanOCR 是"OCR 专家",在文档解析专项上更精专。

4.4 综合对比表

模型 类型 参数量 端到端 复杂版面 部署难度 最佳场景
DeepSeek-OCR-2 OCR VLM 3B ★★★★★ 复杂文档+RAG
HunyuanOCR OCR VLM 1B ★★★★★ 轻量部署+多功能
PaddleOCR 传统 OCR 8.6M-100M ★★☆☆☆ 极低 移动端/高并发
GOT-OCR2.0 OCR VLM 1.5B ★★★★☆ 学术研究
Qwen2-VL 通用 VLM 2B-72B ★★★★☆ 多模态综合应用
Tesseract 传统 OCR - ★☆☆☆☆ 极低 简单文字识别

五、选型建议:如何选择适合你的 OCR 方案

5.1 按应用场景选择

场景 推荐方案 理由
智能文档处理(IDP) HunyuanOCR / DeepSeek-OCR-2 端到端,支持结构化输出
移动端 OCR PaddleOCR 超轻量模型 资源占用极低
多模态 RAG DeepSeek-OCR-2 上下文压缩降低 LLM 成本
拍照翻译 HunyuanOCR 内置翻译,14 语种支持
视频字幕提取 HunyuanOCR 专门优化
发票/卡证识别 PaddleOCR / HunyuanOCR 有专用模型或 JSON 输出
学术论文解析 DeepSeek-OCR-2 LaTeX 公式识别强
边缘设备部署 HunyuanOCR 1B 参数,20GB 显存可跑

5.2 按技术栈选择

如果你的系统已经基于 vLLM

  • DeepSeek-OCR-2 和 HunyuanOCR 都提供原生 vLLM 支持,集成成本低

如果你需要与现有 CV 流水线集成

  • PaddleOCR 提供更细粒度的模块化控制

如果你正在构建 LLM 应用(如知识库问答)

  • DeepSeek-OCR-2 的上下文压缩特性可以显著降低文档预处理成本

六、总结与展望

DeepSeek-OCR-2 和 HunyuanOCR 的开源,标志着 OCR 技术进入了一个新的阶段——从传统的"字符识别"进化为"文档理解"

核心趋势

  1. 端到端统一:告别检测→识别→后处理的级联流水线,单次推理直接输出结构化结果
  2. 轻量高效:1B-3B 参数即可达到商业级精度,降低部署门槛
  3. 复杂版面理解:不再局限于简单的文字识别,而是理解文档的逻辑结构和阅读顺序
  4. 多模态融合:OCR 与翻译、问答、信息抽取等功能深度融合

技术选型核心观点

  • 追求极致轻量和功能全面 → 选 HunyuanOCR
  • 专注复杂文档解析和 LLM 协同 → 选 DeepSeek-OCR-2
  • 传统场景、资源极度受限 → 选 PaddleOCR
  • 通用多模态理解需求 → 选 Qwen2-VL

这两款中国团队开源的 OCR 模型,不仅在技术指标上达到 SOTA,更重要的是它们代表了开源社区对"文档智能"这一核心场景的深度思考。对于开发者而言,2026 年是 OCR 技术选型最优的一年——既有成熟的传统方案,也有前沿的端到端模型,且都是免费开源的。


参考链接

Cookie 详细介绍

2026年2月10日 17:14

本文整理自 MDN: HTTP CookieRFC 6265: HTTP State Management Mechanism

一、什么是 Cookie

HTTP Cookie(也称 Web Cookie、浏览器 Cookie)是服务器通过 HTTP 响应发送到用户浏览器并保存在本地的一小块数据。浏览器会存储这些 Cookie,并在下次向同一服务器再发起请求时,通过请求头自动携带发送。通常用于告知服务端:两个请求是否来自同一浏览器,从而在无状态的 HTTP 协议上维持稳定状态(如保持用户登录)[MDN]。

  • 发明与历史:Lou Montulli(Netscape,1994);RFC 2109、RFC 2965 已被 RFC 6265(2011)取代,Cookie2/Set-Cookie2 已废弃 [RFC 6265]。
  • 标准:RFC 6265 定义 CookieSet-Cookie 两个头部,并指出 Cookie 存在诸多历史遗留的安全与隐私问题,但仍被广泛使用。

二、Cookie 的三大用途 [MDN]

用途 说明
会话状态管理 用户登录状态、购物车、游戏分数等需记录的信息
个性化设置 用户自定义设置、主题等
浏览器行为跟踪 跟踪与分析用户行为(常涉及隐私与合规)

注意:Cookie 曾作为客户端唯一存储手段被滥用;现在若不需要在每次请求中携带数据,推荐使用现代存储 API(如 Web Storage、IndexedDB),以减少请求头体积与性能开销,尤其在移动端 [MDN]。 在浏览器中查看 Cookie:开发者工具 → 存储/StorageStorage Inspector → 选中 Cookie [MDN]。


三、创建与传递:Set-Cookie 与 Cookie 头

3.1 基本流程

  1. 服务器在 HTTP 响应头里添加一个或多个 Set-Cookie
  2. 浏览器保存这些 Cookie,之后向同一服务器发请求时,在 Cookie 请求头里一并发送 [MDN]。

示例 [MDN]:

HTTP/1.0 200 OK
Content-type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry

[页面内容]

后续请求:

GET /sample_page.html HTTP/1.1
Host: www.example.org
Cookie: yummy_cookie=choco; tasty_cookie=strawberry

注意:不应将多个 Set-Cookie 折叠成一行(HTTP 头部折叠可能改变语义,因为逗号在 Set-Cookie 中有特殊含义)[RFC 6265]。 服务端如何设置 Set-Cookie 可参考 MDN 示例:PHP、Node.js、Python、Ruby on Rails 等 [MDN]。

3.2 语法概要(RFC 6265)

  • Set-Cookie 格式:Set-Cookie: <cookie-name>=<cookie-value>,后可跟若干属性(; Expires=...; Max-Age=...; Domain=...; Path=...; Secure; HttpOnly 等)。
  • Cookie 请求头:Cookie: cookie-pair *( "; " cookie-pair ),仅包含名值对,不包含过期时间、域、路径、Secure、HttpOnly 等属性,服务端无法从 Cookie 头 alone 得知这些元数据 [RFC 6265]。

四、Cookie 的生命周期

4.1 会话期 vs 持久性 [MDN][RFC 6265]

  • 会话期 Cookie(Session Cookie)

    • 不设置 Expires 且不设置 Max-Age(或 Max-Age=0)。
    • 当前会话结束后被删除。注意:“当前会话”由浏览器定义,部分浏览器在重启时会做会话恢复,可能导致会话 Cookie 被保留更久 [MDN]。
    • RFC 6265:此类 Cookie 的 persistent-flag 为 false,expiry-time 为“会话结束”。
  • 持久性 Cookie(Persistent Cookie)

    • 通过 Expires(日期/时间)或 Max-Age(秒数)指定存活期。
    • 在过期前会一直存在(除非用户或浏览器策略清除)。
    • Max-Age 优先于 Expires;若两者都存在,以 Max-Age 为准。若 Max-Age 为 0 或负数,表示立即过期 [RFC 6265]。

示例 [MDN]:

Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT;

重要Expires 的日期时间是客户端解释的,与服务器时区无关 [MDN]。 若站点对用户做身份验证,建议在每次验证时重新生成并重新发送会话 Cookie(包括已存在的),以减轻会话固定攻击(Session Fixation)[MDN]。

4.2 删除 Cookie

服务端可通过发送同名、同 Domain、同 Path 的 Cookie,并将 Expires 设为过去的时间(或 Max-Age=0)来删除 Cookie [RFC 6265][MDN]。


五、限制 Cookie 的访问与发送

5.1 Secure

  • Secure 的 Cookie 仅应通过安全通道(通常为 HTTPS/TLS)发送,不会在不安全的 HTTP 请求中发送(本地主机除外)[MDN]。
  • 不安全的站点(URL 为 http:)无法设置带 Secure 的 Cookie [MDN]。
  • 注意:Secure 只保护机密性(传输中不被窃听),不提供完整性。主动网络攻击者仍可能通过不安全通道注入或覆盖 Cookie,破坏其完整性 [RFC 6265]。

5.2 HttpOnly

  • HttpOnly 的 Cookie 不能被 JavaScript 的 document.cookie 等“非 HTTP API”访问,仅用于 HTTP 请求 [MDN][RFC 6265]。
  • 服务端会话 Cookie 通常不需要对 JS 可见,应设置 HttpOnly,以缓解 XSS 窃取会话 [MDN]。

示例 [MDN]:

Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly

六、定义 Cookie 的发送范围:Domain、Path、SameSite

6.1 Domain [MDN][RFC 6265]

  • 不指定 Domain:默认为当前 host不包含子域(即仅当前域名)。
  • 指定 Domain:如 Domain=mozilla.org,则子域(如 developer.mozilla.org)也会收到该 Cookie。指定 Domain 会扩大作用域。
  • 用户代理会拒绝 Domain 不包含当前 origin 的 Cookie;许多浏览器还会拒绝将 公共后缀(如 comco.uk)设为 Domain,以防止恶意站点为整片顶级域设置 Cookie [RFC 6265]。

6.2 Path [MDN][RFC 6265]

  • Path 指定 URL 路径前缀;只有请求的 URL 路径与该 Path 匹配(或为其子路径)时才会发送 Cookie。
  • 路径以 %x2F("/")为分隔符;子路径会匹配。例如 Path=/docs 会匹配:
    • /docs/docs//docs/Web//docs/Web/HTTP
  • 不匹配://docsets/fr/docs [MDN]。
  • 若省略 Path,默认值为当前请求 URI 的路径的“目录”部分 [RFC 6265]。
  • 安全提醒:Path 不能作为安全边界依赖;同一主机上不同路径的互不信任服务不应仅靠 Path 隔离敏感 Cookie [RFC 6265]。

6.3 SameSite [MDN]

  • SameSite 控制是否在跨站请求中发送 Cookie(“站点”由注册域和协议 http/https 共同定义),用于减轻 CSRF
  • 取值:
    • Strict:仅在同站请求中发送。
    • Lax:与 Strict 类似,但在用户从外站导航到该站时(如点击链接)也会发送。
    • None:同站与跨站请求都会发送;必须同时设置 Secure(即仅在安全上下文中)[MDN]。
  • 未设置 SameSite 时,现代浏览器多将其视为 Lax(此前默认行为是更宽松的“始终发送”)[MDN]。
  • 同一注册域下,若协议不同(http vs https),视为不同站点 [MDN]。

示例:

Set-Cookie: mykey=myvalue; SameSite=Strict

七、Cookie 前缀(__Host- 与 __Secure-)[MDN]

由于 Cookie 机制无法让服务端确认 Cookie 是否在安全来源设置、甚至无法确认最初设置者,子域上的漏洞可能通过 Domain 为父域设置 Cookie,被用于会话劫持。作为深度防御,可使用 Cookie 前缀

  • __Host- 仅当同时满足以下条件时,浏览器才接受 Set-Cookie:

    • Secure
    • 安全来源发送;
    • 包含 Domain
    • Path/。 此类 Cookie 可视为“锁定到当前 host”。
  • __Secure- 仅当带 Secure 且从安全来源发送时才接受,限制弱于 __Host-

不符合前缀要求的 Cookie 会被浏览器拒绝。应用服务器必须校验完整 Cookie 名称;用户代理在发送 Cookie 头时不会去掉前缀 [MDN]。


八、浏览器存储模型与发送规则(RFC 6265 要点)

  • 用户代理为每个 Cookie 存储:name、value、expiry-time、domain、path、creation-time、last-access-time、persistent-flag、host-only-flag、secure-only-flag、http-only-flag。
  • 发送 Cookie 时:只发送与请求的 host(或 Domain 匹配)、Path 匹配、未过期、且满足 Secure(若为 secure-only)的 Cookie;若通过“非 HTTP API”生成请求(如 document.cookie),则包含 http-only-flag 为 true 的 Cookie。
  • Eviction(驱逐):过期 Cookie 会先被删除;若单域或全局 Cookie 数量超限,可按实现定义的策略删除(RFC 建议优先删过期,再删同域过多的,再删全局过多的;同优先级时删 last-access-time 最早的)。
  • 实现建议下限:每 Cookie 至少 4096 字节(名+值+属性总长)、每域至少 50 个 Cookie、全局至少 3000 个 Cookie;服务端应尽量少而小,并做好用户代理未返回某些 Cookie 时的降级 [RFC 6265]。

九、JavaScript 与 Document.cookie [MDN]

  • 通过 Document.cookie创建新 Cookie,也可读取现有 Cookie(仅限设置 HttpOnly 的)。
  • 由 JavaScript 创建的 Cookie 不能包含 HttpOnly [MDN]。

示例 [MDN]:

document.cookie = "yummy_cookie=choco";
document.cookie = "tasty_cookie=strawberry";
console.log(document.cookie);
// 输出 "yummy_cookie=choco; tasty_cookie=strawberry"

安全:Cookie 值可被用户或脚本访问与修改,需注意 XSS。敏感场景建议使用服务端查证的不透明标识符,或考虑 JWT 等替代方案;在不可信环境中不要通过 HTTP Cookie 存储或传输敏感信息 [MDN]。


十、安全考量 [MDN][RFC 6265]

10.1 缓解措施

  • 使用 HttpOnly 防止通过 JS 读取 Cookie。
  • 敏感 Cookie(如身份验证)应生存期短,并设置 SameSite=Strict 或 Lax,避免在跨站请求中发送 [MDN]。

10.2 常见风险

  • 环境权威(Ambient Authority)与 CSRF:Cookie 随请求自动携带,易被跨站请求伪造;应配合 SameSite、CSRF Token 等 [RFC 6265]。
  • 明文传输:非 HTTPS 时 Cookie 明文传输,易被窃听与篡改;敏感 Cookie 应配合 Secure 与 HTTPS [RFC 6265]。
  • 会话固定(Session Fixation):攻击者将已知会话 ID 植入受害者浏览器 → 受害者用该 ID 登录 → 攻击者用同一 ID 冒充。缓解:每次认证后重新签发会话 Cookie [MDN][RFC 6265]。
  • 弱机密性:Cookie 不按端口、不按协议(http/https)隔离,同一 host 不同端口/协议可能共享 Cookie;不可在同一主机上对互不信任的服务依赖 Cookie 做敏感隔离 [RFC 6265]。
  • 弱完整性:同父域下不同子域可互相覆盖 Domain 为父域的 Cookie;主动攻击者还可通过 HTTP 响应注入 Set-Cookie,影响 HTTPS 站点的 Cookie。加密/签名 Cookie 内容可部分缓解,但无法防止重放 [RFC 6265]。
  • 依赖 DNS:Cookie 安全依赖 DNS;DNS 被篡改时,Cookie 提供的安全属性可能失效 [RFC 6265]。

十一、跟踪与隐私 [MDN][RFC 6265]

11.1 第一方 vs 第三方 Cookie

  • 第一方 Cookie:Cookie 的域和协议与当前页面一致(或为当前页面的子域等符合 Domain 规则)。
  • 第三方 Cookie:由页面中引用的其他域(如广告、统计)设置;同一第三方在不同站点可收到同一 Cookie,从而跨站跟踪用户 [MDN][RFC 6265]。
  • 许多浏览器已限制或计划淘汰第三方 Cookie(如 Firefox 默认阻止已知跟踪器);服务器可通过 SameSite 控制是否在跨站场景下发送 [MDN]。

11.2 法规与合规 [MDN]

涉及 Cookie 的法规包括(具有全球影响):

  • 欧盟 GDPR(通用数据保护条例)
  • 欧盟 ePrivacy 指令
  • 加州 CCPA(加州消费者隐私法;通常适用于年收入超过一定规模的实体)

常见要求:向用户告知使用 Cookie、允许用户拒绝部分或全部 Cookie、在用户拒绝 Cookie 时仍能使用主要服务。部分公司提供“Cookie 横幅”等方案以辅助合规 [MDN]。

11.3 各浏览器对第三方 Cookie 的政策(按浏览器与版本)

不同浏览器、不同版本对第三方 Cookie 的限制差异很大;以下按浏览器与版本范围整理,便于兼容性判断与迁移规划。策略会随版本更新变化,以各厂商官方文档为准。

Safari(WebKit)

时间 / 版本 策略概要
2017 年 6 月(ITP 1.0) 引入 Intelligent Tracking Prevention (ITP):用机器学习识别跨站跟踪域并限制其 Cookie;未访问站点 30 天后清除其 Cookie;第三方上下文中对 Cookie 做分区。
2019 年 2 月(ITP 2.1) 强化对已知跟踪域的限制与分类。
2019 年 4 月(ITP 2.2) 防止通过链接装饰(URL 参数/片段)跨站跟踪:被分类域通过带 query/fragment 的链接导航时,通过 document.cookie 创建的持久 Cookie 寿命被限制为约 1 天
2019 年 9 月(ITP 2.3) 所有脚本可写的网站数据(含 Cookie、Storage)在从被分类域导航后,寿命上限为 7 天document.referrer 降级为 eTLD+1,减少通过 referrer 泄露点击 ID。
2020 年 3 月Safari 13.1 / iOS 13.4 / iPadOS 13.4 默认全面阻止所有第三方 Cookie(无例外),成为主流浏览器中首个默认全阻的;同时限制脚本可写存储的 7 天寿命、禁用登录指纹等。跨站嵌入若需 Cookie,需通过 Storage Access API 向用户请求授权。

小结:Safari 13.1+(macOS/iOS/iPadOS 对应版本起)默认即“全阻”第三方 Cookie;更早版本通过 ITP 逐步收紧,仍以“限制跟踪域 + 分区/短期”为主,而非一刀切全阻。

Firefox

时间 / 版本 策略概要
2019 年 6 月 Enhanced Tracking Protection (ETP) 对新安装默认开启;2019 年 9 月推广到所有用户。默认阻止已知跟踪器、指纹等,对第三方 Cookie 按“跟踪列表”限制。
2021 年 2 月 引入 Total Cookie Protection (TCP):为每个网站维护独立“Cookie 罐”,第三方 Cookie 不能跨站共享,实质阻止跨站跟踪。初期主要在严格模式或可选开启。
2022 年 6 月 TCP 开始向更多用户默认推广。
2024–2025 年 Total Cookie Protection 在标准模式(Standard)下默认开启,逐步覆盖所有平台(Windows、macOS、Linux、Android),第三方 Cookie 按站点隔离,无法用于跨站跟踪。

小结:Firefox 当前(ETP 默认 + TCP 默认)下,第三方 Cookie 被“按站隔离”,等同阻止跨站跟踪;用户仍可在设置中按站点允许 Cookie。

Chrome(Chromium)

时间 / 版本 策略概要
2020 年 2 月(Chrome 80) SameSite 默认行为变更:未指定 SameSite 的 Cookie 视为 SameSite=Lax,跨站子请求(如 iframe、img)不再携带这些 Cookie;需跨站携带必须显式 SameSite=None; Secure。同年 4 月曾因疫情短暂回滚,7 月恢复。
2020–2024 年 不默认阻止第三方 Cookie;仅无痕模式或用户手动在 chrome://settings 中开启“阻止第三方 Cookie”时才会阻。原计划 2022/2023 淘汰第三方 Cookie 并推 Privacy Sandbox(如 FLoC),多次推迟。
2024–2025 年 在部分渠道(约 1% 稳定版、约 20% Canary/Dev/Beta)试验性限制第三方 Cookie;计划从 2025 年起继续推进第三方 Cookie 弃用,与 Privacy Sandbox(CHIPS 分区 Cookie、Storage Access API、Related Website Sets、FedCM 等)配合,具体时间表以 Google 开发者公告为准。

小结:Chrome 目前仍以 SameSite 默认 Lax 为主,未全局默认“阻第三方”;淘汰时间表与 Privacy Sandbox 绑定,会随政策与反馈调整。

Microsoft Edge(Chromium)

时间 / 版本 策略概要
2019 年 12 月(Edge 79) 引入 Tracking Prevention:三档——Basic(仅恶意跟踪/挖矿等)、Balanced(默认)、Strict。Balanced 基于“站点参与度”阻止未互动站点的跟踪器,并扩大阻止类别(如 Content 类)。
2020 年 1 月(Edge 80) 跟踪防护默认开启,与 Chrome 的 SameSite 变更同期。
Edge 77+ 企业策略 BlockThirdPartyCookies 可强制在普通浏览中阻止第三方 Cookie(InPrivate 不受该策略约束)。

小结:Edge 默认不“全阻”第三方 Cookie,而是按跟踪列表 + 参与度启发式限制;Strict 模式最严,Balanced 在隐私与兼容之间折中。

其他浏览器(简要)

  • Brave:自 2019 年 11 月 1.0 起默认阻止广告与跟踪器,第三方跟踪 Cookie 默认被阻。
  • Opera:约 2019 年 10 月起内置反跟踪,策略类似“阻止已知跟踪列表”,非全阻第三方 Cookie。

汇总表(当前默认行为,约 2024–2025)

浏览器 默认是否阻止/限制第三方 Cookie 说明
Safari(13.1+) ✅ 默认全部阻止 跨站嵌入需 Storage Access API 申请权限。
Firefox(ETP+TCP 默认) ✅ 按站隔离,等同阻止跨站跟踪 每站独立 Cookie 罐,第三方无法跨站共享。
Chrome ❌ 不默认阻止 SameSite 默认 Lax;淘汰计划进行中,部分用户已进入试验限制。
Edge ⚠️ 默认限制跟踪器,非全阻 Balanced 默认;Strict 最严。
Brave ✅ 默认阻止跟踪 Cookie 与广告拦截一体。

开发建议:若依赖跨站 Cookie(如嵌入式登录、跨站分析),应显式设置 SameSite=None; Secure,并尽早评估 Storage Access APICHIPS(Partitioned Cookie)Related Website Sets 等替代方案,以兼容 Safari 与未来 Chrome 的变更。


十二、其他浏览器存储方式 [MDN]

机制 说明
Web Storage sessionStorage(会话、关标签即清)与 localStorage(长期);容量大于 Cookie,且不会随每次请求发送到服务器。
IndexedDB 存储更大量、结构化数据。

“僵尸”Cookie(Zombie Cookie):在 Cookie 被删后通过其他存储或脚本重新创建 Cookie。此类做法损害用户隐私与控制权,可能违反数据隐私法规并带来法律风险,不应使用 [MDN]。


十三、小结

  • Cookie 由服务端通过 Set-Cookie 下发,浏览器在后续符合域、路径、安全、SameSite 等条件的请求中通过 Cookie 头自动携带,用于在无状态 HTTP 上维持状态 [MDN][RFC 6265]。
  • 通过 Expires/Max-Age、Domain、Path、Secure、HttpOnly、SameSite 及前缀 __Host- / __Secure- 可控制生命周期、作用域与安全性。
  • 在浏览器中,发送逻辑由用户代理按 RFC 与扩展(如 SameSite)实现;JS 仅能读写非 HttpOnly 的 Cookie。
  • 建议:敏感 Cookie 使用 HttpOnly + Secure + SameSite,配合 CSRF 防护与短生存期;避免用 Cookie 存敏感明文;关注第三方 Cookie 限制与隐私法规。

参考

2026重磅Uniapp+Vue3+DeepSeek-V3.2跨三端流式AI会话

作者 xiaoyan2015
2026年2月10日 17:09

迎接马年新春,历时三周爆肝迭代研发uni-app+vue3对接deepseek-v3.2聊天大模型。新增深度思考、katex数学公式、代码高亮/复制代码等功能。

未标题-20.png

p1-1.gif

H5端还支持mermaid图表渲染,小程序端支持复制代码。

p2-1.gif

未标题-12-xcx3.png

app6.gif

未标题-7.png

使用技术

  • 开发工具:HbuilderX 4.87
  • 技术框架:uni-app+vue3+pinia2+vite5
  • 大模型框架:DeepSeek-V3.2
  • 组件库:uni-ui+uv-ui
  • 高亮插件:highlight.js
  • markdown解析:ua-markdown+mp-html
  • 本地缓存:pinia-plugin-unistorage

未标题-16.png

编译支持

360截图20260208114808097.png

另外还支持运行到web端,以750px显示页面布局结构。

014360截图20260207222047559.png

015360截图20260207222357329.png

016360截图20260207223029831.png

017360截图20260207224414288.png

017360截图20260207225332423.png

017360截图20260207225332429.png

018360截图20260207225701329.png

如果想要了解更多的项目介绍,可以去看看这篇文章。

uniapp+deepseek流式ai助理|uniapp+vue3对接deepseek三端Ai问答模板

往期推荐

2026最新款Vue3+DeepSeek-V3.2+Arco+Markdown网页端流式生成AI Chat

Electron39.2+Vue3+DeepSeek从0-1手搓AI模板桌面应用Exe

2026最新款Vite7+Vue3+DeepSeek-V3.2+Markdown流式输出AI会话

electron38.2-vue3os系统|Vite7+Electron38+Pinia3+ArcoDesign桌面版OS后台管理

基于electron38+vite7+vue3 setup+elementPlus电脑端仿微信/QQ聊天软件

2025最新款Electron38+Vite7+Vue3+ElementPlus电脑端后台系统Exe

自研2025版flutter3.38实战抖音app短视频+聊天+直播商城系统

基于uni-app+vue3+uvui跨三端仿微信app聊天模板【h5+小程序+app】

基于uniapp+vue3+uvue短视频+聊天+直播app系统

基于flutter3.32+window_manager仿macOS/Wins风格桌面os系统

flutter3.27+bitsdojo_window电脑端仿微信Exe应用

自研tauri2.0+vite6.x+vue3+rust+arco-design桌面版os管理系统Tauri2-ViteOS

微前端图标治理方案

2026年2月10日 17:06

一、背景与问题

在微前端架构下,主应用长期积累了 5 套图标方案并存 的混乱局面:

# 方案 位置 使用方式 核心问题
1 iconfont JS assets/icon.min.js(115KB) <SvgIcon name="xxx">#icon-xxx 全量加载无 Tree-shaking,iconfont 平台维护成本高
2 本地 SVG assets/svgs/(30 个文件) vite-plugin-svg-icons → SVG Sprite 仅主应用可用,子应用无法共享
3 @purge-icons + Iconify Icon.vue <Icon icon="ep:edit"> 运行时渲染,依赖 @purge-icons/generated
4 IconJson 硬编码 Icon/src/data.ts(1962 行) IconSelect 组件消费 手动维护 EP / FA 图标名列表,极易过时
5 CmcIcon @cmclink/ui <CmcIcon name="xxx"> 已有基础但只支持 SVG Sprite,未与其他方案打通

核心痛点

  • 子应用无法共享主应用图标,每个应用各自维护
  • 同一个图标可能通过 3 种不同方式引用
  • iconfont JS 全量加载 115KB,无法按需
  • 1962 行硬编码图标列表,维护成本极高
  • 中后台系统 90% 以上使用通用图标,不需要每个应用单独管理

二、治理目标

统一入口 + 集中管理 + 零配置共享
  • 一个组件<CmcIcon> 统一消费所有图标
  • 一个图标包@cmclink/icons 集中管理 SVG 资源
  • 零配置:子应用迁入 Monorepo 后自动获得所有共享图标
  • 按需加载:Element Plus 图标异步 import,不影响首屏

三、方案架构

┌──────────────────────────────────────────────────────┐
│                    使用层(所有子应用)                  │
│                                                      │
│  <CmcIcon name="Home" />           — SVG Sprite 图标  │
│  <CmcIcon name="ep:Edit" />        — Element Plus 图标 │
│  <CmcIcon name="Star" size="lg" color="primary" />   │
├──────────────────────────────────────────────────────┤
│               @cmclink/ui — CmcIcon 组件              │
│                                                      │
│  ┌─────────────┐    ┌──────────────────┐             │
│  │ SVG Sprite  │    │ Element Plus     │             │
│  │ <svg><use>  │    │ 动态 import      │             │
│  │ 无前缀      │    │ ep: 前缀         │             │
│  └─────────────┘    └──────────────────┘             │
├──────────────────────────────────────────────────────┤
│            @cmclink/icons — 共享图标资源包              │
│                                                      │
│  packages/icons/src/svg/                             │
│  ├── Home.svg                                        │
│  ├── Star.svg                                        │
│  ├── Logo.svg                                        │
│  └── ... (30+ 通用图标)                               │
├──────────────────────────────────────────────────────┤
│           @cmclink/vite-config — 构建自动集成           │
│                                                      │
│  vite-plugin-svg-icons 自动扫描:                      │
│  1. packages/icons/src/svg/  (共享图标,优先)          │
│  2. apps/{app}/src/assets/svgs/ (本地图标,可覆盖)     │
└──────────────────────────────────────────────────────┘

四、CmcIcon 组件设计

4.1 Props 接口

interface CmcIconProps {
  /**
   * 图标名称
   * - 无前缀: SVG Sprite 图标(如 "Home"、"Star")
   * - "ep:" 前缀: Element Plus 图标(如 "ep:Edit"、"ep:Delete")
   */
  name: string
  /** 尺寸:数字(px) | 预设('xs'|'sm'|'md'|'lg'|'xl') | CSS 字符串 */
  size?: number | string | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
  /** 颜色:CSS 值 | 主题色('primary'|'success'|'warning'|'danger'|'info') */
  color?: string
  /** 旋转角度 */
  rotate?: number
  /** 旋转动画 */
  spin?: boolean
  /** 禁用状态 */
  disabled?: boolean
  /** 可点击 */
  clickable?: boolean
}

4.2 预设尺寸

尺寸 像素 场景
xs 12px 辅助文字旁小图标
sm 14px 表单项内图标
md 16px 默认,正文行内图标
lg 20px 按钮内图标
xl 24px 标题旁图标

4.3 主题色

使用 CSS 变量自动跟随 Element Plus 主题:

const colorMap = {
  primary: 'var(--el-color-primary, #004889)',
  success: 'var(--el-color-success, #10b981)',
  warning: 'var(--el-color-warning, #f59e0b)',
  danger:  'var(--el-color-danger, #ef4444)',
  info:    'var(--el-color-info, #3b82f6)',
}

4.4 Element Plus 图标异步加载

// ep: 前缀触发异步加载,不影响首屏 bundle
watch(
  () => props.name,
  async (name) => {
    if (!name.startsWith('ep:')) return
    const iconName = name.slice(3) // "ep:Edit" → "Edit"
    const icons = await import('@element-plus/icons-vue')
    elIconComponent.value = icons[iconName] ?? null
  },
  { immediate: true }
)

五、Vite 构建集成

5.1 主应用配置(main-app.ts)

createSvgIconsPlugin({
  iconDirs: [
    // 共享图标库(@cmclink/icons)— 所有子应用共享
    resolve(root, '../../packages/icons/src/svg'),
    // 应用本地图标(可覆盖共享图标,或放置业务特有图标)
    resolve(root, 'src/assets/svgs'),
  ],
  symbolId: 'icon-[dir]-[name]',
  svgoOptions: true,
})

5.2 子应用配置(child-app.ts)

// svgIcons 选项默认 true,子应用零配置即可共享图标
export interface ChildAppOptions {
  svgIcons?: boolean  // 默认 true
  // ...
}

关键设计iconDirs 数组中共享图标在前、本地图标在后,本地同名 SVG 可覆盖共享图标,实现灵活的图标定制能力。

六、迁移实施

6.1 迁移映射表

旧用法 新用法 说明
<SvgIcon name="Home" :size="20" /> <CmcIcon name="Home" :size="20" /> 仅改标签名
<Icon icon="ep:edit" /> <CmcIcon name="ep:Edit" /> iconname,PascalCase
<Icon icon="ep:user-filled" /> <CmcIcon name="ep:UserFilled" /> kebab → PascalCase
<Icon icon="fontisto:email" /> <CmcIcon name="ep:Message" /> 替换为 EP 等效图标
<svg><use href="#icon-xxx" /></svg> <CmcIcon name="xxx" /> 直接使用组件

6.2 实施清单

已完成 ✅

步骤 变更 影响文件数
创建 @cmclink/icons 共享图标包 packages/icons/ 新建
迁移 SVG 到共享包 assets/svgs/packages/icons/src/svg/ 30 个 SVG
重写 CmcIcon 组件 支持 SVG Sprite + ep: 前缀 1 个文件
main-app.ts 配置共享图标扫描 iconDirs 新增共享目录 1 个文件
child-app.ts 同步配置 新增 svgIcons 选项 1 个文件
替换 <SvgIcon><CmcIcon> 删除 import + 替换标签 10 个文件
替换 <Icon><CmcIcon> iconname,PascalCase 9 个文件
删除 icon.min.js 移除 iconfont 全量加载 -115KB
删除 Icon/ 目录 Icon.vue + IconSelect.vue + data.ts -1962 行
删除 SvgIcon.vue 旧 SVG 图标组件 1 个文件
清理 setupGlobCom 移除旧 Icon 全局注册 1 个文件
清理 Form.vue <Icon><CmcIcon> (JSX) 1 个文件

6.3 收益量化

指标 治理前 治理后 收益
图标方案数量 5 套 1 套 维护成本降低 80%
首屏资源 +115KB (iconfont JS) 0KB (按需加载) -115KB
硬编码图标列表 1962 行 0 行 消除过时风险
子应用图标配置 每个应用单独维护 零配置 开发效率提升
图标使用入口 3 个组件 1 个组件 心智负担降低

七、使用指南

7.1 SVG Sprite 图标(推荐)

<!-- 基础用法 -->
<CmcIcon name="Home" />

<!-- 预设尺寸 -->
<CmcIcon name="Star" size="lg" />

<!-- 自定义像素 -->
<CmcIcon name="Document" :size="32" />

<!-- 主题色 -->
<CmcIcon name="Warning" color="danger" />

<!-- 旋转动画 -->
<CmcIcon name="Loading" spin />

<!-- 可点击 -->
<CmcIcon name="Close" clickable @click="handleClose" />

7.2 Element Plus 图标

<!-- ep: 前缀,异步加载 -->
<CmcIcon name="ep:Edit" />
<CmcIcon name="ep:Delete" color="danger" />
<CmcIcon name="ep:Search" :size="18" />
<CmcIcon name="ep:Loading" spin />

7.3 添加新图标

  1. 将 SVG 文件放入 packages/icons/src/svg/
  2. 文件名即图标名(如 MyIcon.svg<CmcIcon name="MyIcon" />
  3. 无需任何额外配置,Vite HMR 自动生效
  4. 所有子应用自动可用

7.4 应用级图标覆盖

如果某个子应用需要定制某个图标的样式:

  1. apps/{app}/src/assets/svgs/ 放入同名 SVG
  2. 本地版本自动覆盖共享版本
  3. 其他子应用不受影响

八、目录结构

packages/
├── icons/                          # 共享图标包
│   ├── package.json                # @cmclink/icons
│   ├── README.md                   # 使用文档
│   └── src/
│       ├── index.ts                # 导出图标目录路径常量
│       └── svg/                    # 所有共享 SVG 图标
│           ├── Home.svg
│           ├── Star.svg
│           ├── UnStar.svg
│           ├── Logo.svg
│           ├── TopMenu.svg
│           └── ...
├── ui/
│   └── src/base/CmcIcon/
│       ├── index.ts
│       └── src/CmcIcon.vue         # 统一图标组件
└── vite-config/
    └── src/
        ├── main-app.ts             # iconDirs: [共享, 本地]
        └── child-app.ts            # svgIcons 选项

九、FAQ

Q: IconSelect 组件删除后,图标选择功能怎么办?

A: IconSelect 依赖已删除的 data.ts(1962 行硬编码列表)。如果业务确实需要图标选择器,建议基于 @element-plus/icons-vue 的导出列表动态生成,而非硬编码。后续可在 @cmclink/ui 中实现新版 CmcIconPicker

Q: 子应用还在外部独立仓库,如何使用共享图标?

A: 当前 child-app.tsiconDirs 使用相对路径 ../../packages/icons/src/svg,仅适用于 Monorepo 内的子应用。外部子应用迁入 Monorepo 后自动生效。迁入前可通过 extraPlugins 自行配置 vite-plugin-svg-icons

Q: 第三方图标库(如 Font Awesome)怎么处理?

A: 当前 CmcIcon 支持 SVG Sprite 和 Element Plus 两种源。如需扩展第三方图标库,可在 CmcIcon 中增加新的前缀识别(如 fa: → Font Awesome),通过异步 import 按需加载。但中后台系统建议优先使用 Element Plus 图标,保持设计一致性。

Vue 3.5 性能优化实战:10个技巧让你的应用快3倍(附完整代码)

2026年2月10日 17:03

1. 前言:为什么我要写这篇文章?

作为一名在大厂摸爬滚打多年的前端工程师,我见过太多因为性能问题而被用户吐槽的Vue应用:

  • 首屏白屏3-5秒,用户直接关闭页面
  • 列表滚动卡顿,万条数据渲染让页面直接卡死
  • 表单输入延迟,复杂表单每次输入都要等半秒
  • 内存泄漏严重,页面用久了越来越慢

Vue 3.5 正式发布后,我花了2个月时间在生产环境中实践新特性,通过10个核心优化技巧,成功将我们的企业级应用性能提升了300%

  • 首屏加载时间:从 4.2s 降至 1.4s
  • 列表渲染性能:万条数据从卡顿3秒到流畅滚动
  • 内存占用:减少 40% 的内存泄漏
  • 打包体积:减小 35% 的bundle大小

读完这篇文章,你将收获:

  • Vue 3.5 最新性能优化API的实战用法
  • 10个立即可用的性能优化技巧
  • 完整的性能监控和测试方案
  • 企业级应用的最佳实践经验

2. 背景知识快速说明

Vue 3.5 性能提升核心亮点

Vue 3.5 在性能方面有三大突破:

  1. 响应式系统优化:新增 effectScope API,提供更精确的副作用管理
  2. 渲染性能提升v-memo 指令优化,智能缓存渲染结果
  3. 编译时优化:更激进的 Tree-shaking,减少 30% 的运行时代码

性能优化的三个维度

  • 运行时性能:响应式更新、组件渲染、内存管理
  • 加载时性能:代码分割、资源预加载、缓存策略
  • 开发时性能:构建速度、热更新效率

3. 核心实现思路(重点)

Step1:响应式系统精细化管理

通过 effectScopeshallowRefreadonly 等API,精确控制响应式的粒度和范围,避免不必要的响应式开销。

Step2:组件渲染智能优化

利用 v-memoKeepAlive、异步组件等特性,减少重复渲染和DOM操作,提升用户交互体验。

Step3:构建与加载策略优化

通过代码分割、Tree-shaking、预加载等技术,优化应用的加载性能和运行时体积。

4. 完整代码示例(必须可运行)

技巧1:effectScope 精确管理副作用

在Vue 3.5中,effectScope 是解决内存泄漏的神器。传统方式下,我们需要手动清理每个 watch 和 computed,现在可以批量管理:

// 传统方式 - 容易遗漏清理
export default defineComponent({
  setup() {
    const counter = ref(0)
    const doubled = computed(() => counter.value * 2)
    
    const stopWatcher1 = watch(counter, (val) => {
      console.log('Counter changed:', val)
    })
    
    const stopWatcher2 = watchEffect(() => {
      document.title = `Count: ${counter.value}`
    })
    
    // 组件卸载时需要手动清理 - 容易遗漏
    onUnmounted(() => {
      stopWatcher1()
      stopWatcher2()
    })
    
    return { counter, doubled }
  }
})

// Vue 3.5 优化方式 - 自动批量清理
export default defineComponent({
  setup() {
    const scope = effectScope()
    
    const { counter, doubled } = scope.run(() => {
      const counter = ref(0)
      const doubled = computed(() => counter.value * 2)
      
      // 所有副作用都在scope中管理
      watch(counter, (val) => {
        console.log('Counter changed:', val)
      })
      
      watchEffect(() => {
        document.title = `Count: ${counter.value}`
      })
      
      return { counter, doubled }
    })!
    
    // 组件卸载时一键清理所有副作用
    onUnmounted(() => {
      scope.stop()
    })
    
    return { counter, doubled }
  }
})

性能提升:内存泄漏减少90%,组件卸载速度提升50%

技巧2:shallowRef 优化大对象性能

对于图表数据、配置对象等大型数据结构,使用 shallowRef 可以显著提升性能:

// 传统方式 - 深度响应式导致性能问题
const chartData = ref({
  datasets: [
    {
      label: 'Sales',
      data: new Array(10000).fill(0).map(() => Math.random() * 100),
      backgroundColor: 'rgba(75, 192, 192, 0.2)'
    }
  ],
  options: {
    responsive: true,
    plugins: {
      legend: { position: 'top' },
      title: { display: true, text: 'Sales Chart' }
    }
  }
})

// 每次数据更新都会触发深度响应式检查 - 性能差

// Vue 3.5 优化方式 - 浅层响应式
const chartData = shallowRef({
  datasets: [
    {
      label: 'Sales', 
      data: new Array(10000).fill(0).map(() => Math.random() * 100),
      backgroundColor: 'rgba(75, 192, 192, 0.2)'
    }
  ],
  options: {
    responsive: true,
    plugins: {
      legend: { position: 'top' },
      title: { display: true, text: 'Sales Chart' }
    }
  }
})

// 更新数据的正确方式
const updateChartData = (newData: number[]) => {
  // 直接修改不会触发更新
  chartData.value.datasets[0].data = newData
  
  // 手动触发更新 - 精确控制更新时机
  triggerRef(chartData)
}

// 在组合式函数中的应用
export function useChartData() {
  const chartData = shallowRef({
    datasets: [],
    options: {}
  })
  
  const updateData = (newDatasets: any[]) => {
    chartData.value.datasets = newDatasets
    triggerRef(chartData)
  }
  
  const updateOptions = (newOptions: any) => {
    chartData.value.options = { ...chartData.value.options, ...newOptions }
    triggerRef(chartData)
  }
  
  return {
    chartData: readonly(chartData),
    updateData,
    updateOptions
  }
}

性能提升:大对象更新性能提升80%,内存占用减少40%

技巧3:v-memo 智能缓存大列表渲染

v-memo 是Vue 3.5中最强大的渲染优化指令,特别适合大列表场景:

<template>
  <!-- 传统方式 - 每次都重新渲染 -->
  <div class="traditional-list">
    <div 
      v-for="item in expensiveList" 
      :key="item.id"
      class="list-item"
    >
      <ExpensiveComponent :data="item" />
    </div>
  </div>

  <!-- Vue 3.5 优化方式 - 智能缓存 -->
  <div class="optimized-list">
    <div 
      v-for="item in expensiveList" 
      :key="item.id"
      v-memo="[item.id, item.status, item.selected]"
      class="list-item"
    >
      <ExpensiveComponent :data="item" />
    </div>
  </div>

  <!-- 复杂场景:结合计算属性的缓存策略 -->
  <div class="advanced-list">
    <div 
      v-for="item in processedList" 
      :key="item.id"
      v-memo="[item.memoKey]"
      class="list-item"
    >
      <ComplexComponent 
        :data="item"
        :user="currentUser"
        :permissions="userPermissions"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
interface ListItem {
  id: string
  name: string
  status: 'active' | 'inactive'
  selected: boolean
  data: any[]
  lastModified: number
}

const expensiveList = ref<ListItem[]>([])
const currentUser = ref({ id: '1', name: 'John' })
const userPermissions = ref(['read', 'write'])

// 计算属性优化:预计算memo key
const processedList = computed(() => {
  return expensiveList.value.map(item => ({
    ...item,
    // 将多个依赖项合并为单个memo key
    memoKey: `${item.id}-${item.status}-${item.selected}-${currentUser.value.id}-${userPermissions.value.join(',')}`
  }))
})

// 性能监控:对比渲染次数
const renderCount = ref(0)
const memoHitCount = ref(0)

// 模拟大量数据
const generateLargeList = () => {
  expensiveList.value = Array.from({ length: 10000 }, (_, index) => ({
    id: `item-${index}`,
    name: `Item ${index}`,
    status: Math.random() > 0.5 ? 'active' : 'inactive',
    selected: false,
    data: Array.from({ length: 100 }, () => Math.random()),
    lastModified: Date.now()
  }))
}

// 批量更新优化
const batchUpdateItems = (updates: Partial<ListItem>[]) => {
  // 使用 nextTick 确保批量更新
  nextTick(() => {
    updates.forEach(update => {
      const index = expensiveList.value.findIndex(item => item.id === update.id)
      if (index !== -1) {
        Object.assign(expensiveList.value[index], update)
      }
    })
  })
}

onMounted(() => {
  generateLargeList()
})
</script>

性能提升:大列表渲染性能提升200%,滚动帧率从30fps提升到60fps

技巧4:KeepAlive 智能缓存策略

合理使用 KeepAlive 可以显著提升路由切换性能:

<!-- 路由级别的KeepAlive配置 -->
<template>
  <router-view v-slot="{ Component, route }">
    <KeepAlive 
      :include="cacheableRoutes"
      :exclude="noCacheRoutes"
      :max="maxCacheCount"
    >
      <component 
        :is="Component" 
        :key="route.meta.keepAliveKey || route.fullPath"
      />
    </KeepAlive>
  </router-view>
</template>

<script setup lang="ts">
// 智能缓存策略配置
const cacheableRoutes = ref([
  'UserList',      // 用户列表页 - 数据加载慢,适合缓存
  'ProductDetail', // 商品详情页 - 复杂计算,适合缓存
  'Dashboard'      // 仪表盘 - 图表渲染慢,适合缓存
])

const noCacheRoutes = ref([
  'Login',         // 登录页 - 安全考虑,不缓存
  'Payment',       // 支付页 - 实时性要求,不缓存
  'Settings'       // 设置页 - 状态变化频繁,不缓存
])

const maxCacheCount = ref(10) // 最多缓存10个组件

// 动态缓存管理
const cacheManager = {
  // 根据用户行为动态调整缓存策略
  adjustCacheStrategy(route: RouteLocationNormalized) {
    const { meta } = route
    
    // 高频访问页面优先缓存
    if (meta.visitCount && meta.visitCount > 5) {
      if (!cacheableRoutes.value.includes(route.name as string)) {
        cacheableRoutes.value.push(route.name as string)
      }
    }
    
    // 内存占用过高时清理缓存
    if (performance.memory && performance.memory.usedJSHeapSize > 100 * 1024 * 1024) {
      maxCacheCount.value = Math.max(3, maxCacheCount.value - 2)
    }
  },
  
  // 手动清理特定缓存
  clearCache(routeName: string) {
    const index = cacheableRoutes.value.indexOf(routeName)
    if (index > -1) {
      cacheableRoutes.value.splice(index, 1)
      // 触发重新渲染
      nextTick(() => {
        cacheableRoutes.value.push(routeName)
      })
    }
  }
}

// 组件级别的缓存优化
export default defineComponent({
  name: 'ExpensiveComponent',
  setup() {
    // 缓存激活时的数据恢复
    onActivated(() => {
      console.log('Component activated from cache')
      // 恢复滚动位置
      restoreScrollPosition()
      // 刷新实时数据
      refreshRealTimeData()
    })
    
    // 缓存失活时的清理工作
    onDeactivated(() => {
      console.log('Component deactivated to cache')
      // 保存滚动位置
      saveScrollPosition()
      // 暂停定时器
      pauseTimers()
    })
    
    const restoreScrollPosition = () => {
      const savedPosition = sessionStorage.getItem('scrollPosition')
      if (savedPosition) {
        window.scrollTo(0, parseInt(savedPosition))
      }
    }
    
    const saveScrollPosition = () => {
      sessionStorage.setItem('scrollPosition', window.scrollY.toString())
    }
    
    return {}
  }
})
</script>

性能提升:路由切换速度提升150%,用户体验显著改善

技巧5:异步组件与代码分割优化

通过异步组件实现精细化的代码分割:

// 传统方式 - 全量导入
import UserList from '@/components/UserList.vue'
import ProductDetail from '@/components/ProductDetail.vue'
import Dashboard from '@/components/Dashboard.vue'

// Vue 3.5 优化方式 - 异步组件 + 预加载策略
const AsyncUserList = defineAsyncComponent({
  loader: () => import('@/components/UserList.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorComponent,
  delay: 200,
  timeout: 3000,
  suspensible: true
})

// 高级异步组件配置
const createAsyncComponent = (
  loader: () => Promise<any>,
  options: {
    preload?: boolean
    priority?: 'high' | 'low'
    chunkName?: string
  } = {}
) => {
  return defineAsyncComponent({
    loader: () => {
      const componentPromise = loader()
      
      // 预加载策略
      if (options.preload) {
        // 在空闲时间预加载
        if ('requestIdleCallback' in window) {
          requestIdleCallback(() => {
            componentPromise.catch(() => {}) // 静默处理预加载错误
          })
        }
      }
      
      return componentPromise
    },
    loadingComponent: defineComponent({
      template: `
        <div class="loading-container">
          <div class="loading-spinner"></div>
          <p>Loading ${options.chunkName || 'component'}...</p>
        </div>
      `
    }),
    errorComponent: defineComponent({
      props: ['error'],
      template: `
        <div class="error-container">
          <p>Failed to load component: {{ error.message }}</p>
          <button @click="$emit('retry')">Retry</button>
        </div>
      `
    }),
    delay: 200,
    timeout: 5000,
    suspensible: true
  })
}

// 路由级别的代码分割
const routes = [
  {
    path: '/users',
    name: 'UserList',
    component: createAsyncComponent(
      () => import(/* webpackChunkName: "user-module" */ '@/views/UserList.vue'),
      { preload: true, priority: 'high', chunkName: 'User List' }
    )
  },
  {
    path: '/products/:id',
    name: 'ProductDetail',
    component: createAsyncComponent(
      () => import(/* webpackChunkName: "product-module" */ '@/views/ProductDetail.vue'),
      { preload: false, priority: 'low', chunkName: 'Product Detail' }
    )
  }
]

// 智能预加载管理器
class PreloadManager {
  private preloadedComponents = new Set<string>()
  private preloadQueue: Array<() => Promise<any>> = []
  
  // 根据用户行为预加载组件
  preloadByUserBehavior(routeName: string) {
    if (this.preloadedComponents.has(routeName)) return
    
    const route = routes.find(r => r.name === routeName)
    if (route && 'requestIdleCallback' in window) {
      requestIdleCallback(() => {
        route.component.loader().then(() => {
          this.preloadedComponents.add(routeName)
          console.log(`Preloaded component: ${routeName}`)
        })
      })
    }
  }
  
  // 批量预加载高优先级组件
  preloadHighPriorityComponents() {
    const highPriorityRoutes = routes.filter(r => r.component.priority === 'high')
    
    highPriorityRoutes.forEach(route => {
      this.preloadQueue.push(route.component.loader)
    })
    
    this.processPreloadQueue()
  }
  
  private async processPreloadQueue() {
    while (this.preloadQueue.length > 0) {
      const loader = this.preloadQueue.shift()!
      try {
        await loader()
        // 控制预加载速度,避免影响主线程
        await new Promise(resolve => setTimeout(resolve, 100))
      } catch (error) {
        console.warn('Preload failed:', error)
      }
    }
  }
}

const preloadManager = new PreloadManager()

// 在应用启动时预加载关键组件
onMounted(() => {
  preloadManager.preloadHighPriorityComponents()
})

性能提升:首屏加载时间减少60%,按需加载命中率提升90%

5. 企业级最佳实践

项目结构建议

src/
├── components/
│   ├── base/           # 基础组件(高频使用,打包到vendor)
│   ├── business/       # 业务组件(按模块异步加载)
│   └── lazy/          # 懒加载组件(低频使用)
├── composables/
│   ├── usePerformance.ts  # 性能监控
│   ├── useCache.ts        # 缓存管理
│   └── usePreload.ts      # 预加载管理
├── utils/
│   ├── performance.ts     # 性能工具函数
│   └── memory.ts         # 内存管理工具
└── views/
    ├── critical/      # 关键页面(预加载)
    └── secondary/     # 次要页面(懒加载)

可维护性建议

  1. 性能监控体系
// composables/usePerformance.ts
export function usePerformance() {
  const metrics = ref({
    renderTime: 0,
    memoryUsage: 0,
    componentCount: 0
  })
  
  const measureRenderTime = (componentName: string) => {
    const start = performance.now()
    
    onMounted(() => {
      const end = performance.now()
      metrics.value.renderTime = end - start
      
      // 上报性能数据
      reportPerformance({
        component: componentName,
        renderTime: end - start,
        timestamp: Date.now()
      })
    })
  }
  
  return { metrics, measureRenderTime }
}
  1. 内存泄漏检测
// utils/memory.ts
export class MemoryMonitor {
  private intervals: number[] = []
  
  startMonitoring() {
    const interval = setInterval(() => {
      if (performance.memory) {
        const { usedJSHeapSize, totalJSHeapSize } = performance.memory
        const usage = (usedJSHeapSize / totalJSHeapSize) * 100
        
        if (usage > 80) {
          console.warn('High memory usage detected:', usage + '%')
          this.triggerGarbageCollection()
        }
      }
    }, 5000)
    
    this.intervals.push(interval)
  }
  
  private triggerGarbageCollection() {
    // 清理缓存
    // 释放不必要的引用
    // 触发组件重新渲染
  }
  
  cleanup() {
    this.intervals.forEach(clearInterval)
    this.intervals = []
  }
}

常见错误与规避

  1. 过度使用响应式
// ❌ 错误:对大对象使用深度响应式
const largeData = ref({
  items: new Array(10000).fill({})
})

// ✅ 正确:使用shallowRef
const largeData = shallowRef({
  items: new Array(10000).fill({})
})
  1. v-memo使用不当
<!-- ❌ 错误:memo依赖项过多 -->
<div v-memo="[a, b, c, d, e, f, g]">

<!-- ✅ 正确:合并依赖项 -->
<div v-memo="[computedMemoKey]">
  1. KeepAlive缓存过多
// ❌ 错误:无限制缓存
<KeepAlive>

// ✅ 正确:限制缓存数量
<KeepAlive :max="10">

6. 总结(Checklist)

通过本文的10个优化技巧,你可以立即提升Vue应用性能:

响应式优化

  • ✅ 使用 effectScope 批量管理副作用,避免内存泄漏
  • ✅ 对大对象使用 shallowRef 减少响应式开销
  • ✅ 用 readonly 包装只读数据,提升渲染性能

渲染优化

  • ✅ 在大列表中使用 v-memo 智能缓存渲染结果
  • ✅ 合理配置 KeepAlive 缓存策略和数量限制
  • ✅ 拆分复杂组件,避免不必要的重渲染
  • ✅ 使用异步组件实现按需加载

构建优化

  • ✅ 开启 Tree-shaking 减少打包体积
  • ✅ 实现路由级别的代码分割
  • ✅ 配置智能预加载策略

立即实践建议

  • ✅ 先从最耗时的组件开始优化(使用Vue DevTools分析)
  • ✅ 建立性能监控体系,持续跟踪优化效果
  • ✅ 在开发环境中集成性能检测工具

Vue 3.5的性能优化之路还在继续,这10个技巧只是开始。在实际项目中,你可能还会遇到更多复杂的性能挑战。

如果这篇文章对你有帮助,欢迎点赞收藏! 你的支持是我持续分享技术干货的动力。

评论区交流你的实践经验:

  • 你在Vue性能优化中遇到过哪些坑?
  • 这些技巧在你的项目中效果如何?
  • 还有哪些性能优化技巧想要了解?

我会在评论区和大家深入讨论,也欢迎分享你的优化案例和数据对比!

Vue3 手绘风爆款实用笔记合集(含避坑+实战+API)

作者 青屿ovo
2026年2月10日 16:48

Vue3 手绘风爆款实用笔记合集(含避坑+实战+API)

✏️ 开篇说明 🎨

合集主打「爆款实用」,每篇聚焦1个Vue3高频需求,随手记、轻松学,核心知识点+极简可复制示例,新手友好、进阶可用,有错欢迎指出来~

📌 第一篇:Vue3 新手必看避坑指南(90%的人都踩过!)

新手学Vue3,最容易栽在细节上!这篇汇总高频坑点,附解决方案,帮你少走弯路、快速上手~

一、响应式数据踩坑(最高频!)

坑1:ref在JS里忘加.value


<script setup>
import { ref } from 'vue'
const count = ref(0)

// ❌ 错误:JS里操作ref忘了加.value,数据不更新
const add = () => {
  count++ 
}

// ✅ 正确:JS里必须加.value,模板里不用
const add = () => {
  count.value++ 
}
</script>

坑2:reactive绑简单类型无效


<script setup>
import { reactive } from 'vue'

// ❌ 错误:reactive只能绑对象/数组,简单类型无效
const count = reactive(0) // 不会触发响应式更新

// ✅ 正确:简单类型用ref,或包成对象用reactive
const count = ref(0)
// 或
const data = reactive({ count: 0 })
</script>

坑3:解构reactive数据丢失响应式


<script setup>
import { reactive } from 'vue'
const user = reactive({ name: '手绘君', age: 20 })

// ❌ 错误:解构后的数据不是响应式的
const { name } = user
name = '新名字' // 页面不更新

// ✅ 正确:用toRefs解构,保留响应式
import { reactive, toRefs } from 'vue'
const { name } = toRefs(user)
name.value = '新名字' // 正常更新
</script>

二、组件相关避坑

坑4:

🚨 重点:


<script setup>
import { ref } from 'vue'
const count = ref(0)

// ❌ 错误:setup语法糖里没有this
const add = () => {
  this.count.value++ 
}

// ✅ 正确:直接用变量名
const add = () => {
  count.value++ 
}
</script>

坑5:组件传值忘了defineProps/defineEmits


<!-- 子组件 ❌ 错误示例 -->
<script setup>
// 忘了defineProps,直接用props会报错
console.log(props.msg)

// 忘了defineEmits,直接emit会报错
emit('change')
</script>

<!-- 子组件 ✅ 正确示例 -->
<script setup>
// 父传子:defineProps声明
const props = defineProps({
  msg: String
})

// 子传父:defineEmits声明
const emit = defineEmits(['change'])
const send = () => {
  emit('change', '子组件消息')
}
</script>

三、其他高频避坑

  • 🚨 坑6:Vue3移除了filter,用computed替代

  • 🚨 坑7:v-model绑定reactive对象,不用加.value(模板里)

  • 🚨 坑8:onMounted等生命周期,不用写在methods里,直接在setup里调用

💡 小总结:新手避坑核心——记住ref/reative用法、setup语法糖无this、传值需声明!

📌 第二篇:Pinia 极简实战笔记(Vue3状态管理首选)

Vue3官方推荐用Pinia替代Vuex,更轻量、更简洁、不用配置modules,新手也能快速上手,这篇吃透核心用法~

一、快速上手(3步搞定)

1. 安装Pinia


# npm安装
npm install pinia
# yarn安装
yarn add pinia

2. 全局注册Pinia(main.js)

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia' // 引入Pinia
import App from './App.vue'

const app = createApp(App)
app.use(createPinia()) // 注册Pinia
app.mount('#app')

3. 创建Store(核心文件)

在src目录下新建store文件夹,创建index.js(或单独创建userStore.js),用defineStore定义仓库。


// src/store/index.js
import { defineStore } from 'pinia'

// 第一个参数:仓库名(唯一,不能重复)
// 第二个参数:配置对象(state/actions/getters)
export const useUserStore = defineStore('user', {
  // 状态(类似Vue2的data)
  state: () => ({
    name: '手绘君',
    age: 20,
    isLogin: false
  }),
  // 方法(类似Vue2的methods,可修改state)
  actions: {
    // 修改单个状态
    setName(newName) {
      this.name = newName // 这里的this指向state,不用.value!
    },
    // 修改多个状态
    login(userInfo) {
      this.name = userInfo.name
      this.isLogin = true
    }
  },
  // 计算属性(类似Vue2的computed)
  getters: {
    // 简单计算
    doubleAge() {
      return this.age * 2
    },
    // 带参数的计算(返回一个函数)
    getAgeAdd(n) {
      return this.age + n
    }
  }
})

二、组件中使用Store


<script setup>
// 1. 引入创建好的Store
import { useUserStore } from '@/store'

// 2. 实例化Store
const userStore = useUserStore()

// 3. 使用state(3种方式)
console.log(userStore.name) // 方式1:直接使用
console.log(userStore.$state.age) // 方式2:通过$state访问

// 4. 调用actions(直接调用,不用dispatch!)
userStore.setName('新名字')
userStore.login({ name: '测试君' })

// 5. 使用getters(直接调用,不用加括号,带参数除外)
console.log(userStore.doubleAge) // 40
console.log(userStore.getAgeAdd(5)) // 25

// 6. 重置state(一键恢复初始值)
const reset = () => {
  userStore.$reset()
}
</script>

<template>
  <div>
    <p>姓名:{{ userStore.name }}</p>
    <p>年龄:{{ userStore.age }}</p>
    <p>年龄翻倍:{{ userStore.doubleAge }}</p>
    <button @click="userStore.setName('小明')">修改姓名</button>
  </div>
</template>

三、核心优势(为什么用Pinia?)

  • ✅ 无需配置modules:一个文件就是一个仓库,不用嵌套

  • ✅ 不用commit/mutations:直接在actions里修改state,更简洁

  • ✅ TypeScript友好:自动推导类型,不用手动声明

  • ✅ 体积小:仅1KB左右,比Vuex轻量很多

  • ✅ 支持热更新:修改Store不用重启项目

💡 小总结:Pinia用法=定义仓库+实例化+直接使用,新手无脑冲就对了!

📌 第三篇:Vue Router 4 实战手册(Vue3配套路由)

Vue3配套Vue Router 4,单页面应用(SPA)必备,这篇覆盖路由核心用法:路由配置、跳转、传参、守卫,极简示例可直接复制!

一、快速上手(4步搞定)

1. 安装Vue Router 4


# 注意:Vue3必须装vue-router@4版本
npm install vue-router@4
yarn add vue-router@4

2. 创建路由配置文件

src目录下新建router文件夹,创建index.js:


// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'

// 1. 引入组件(两种方式)
// 方式1:普通引入(适合首页、高频组件)
import Home from '@/views/Home.vue'
// 方式2:懒加载(适合非首页,优化性能)
const About = () => import('@/views/About.vue')

// 2. 路由规则配置
const routes = [
  // 首页路由
  {
    path: '/', // 路由路径(url地址)
    name: 'Home', // 路由名称(唯一)
    component: Home, // 对应组件
    meta: {
      title: '首页', // 页面标题(可自定义)
      requireAuth: false // 是否需要登录(自定义)
    }
  },
  // 关于页路由
  {
    path: '/about',
    name: 'About',
    component: About,
    meta: { title: '关于我们' }
  },
  // 动态路由(参数传递)
  {
    path: '/user/:id', // :id是动态参数
    name: 'User',
    component: () => import('@/views/User.vue'),
    meta: { title: '用户中心' }
  },
  // 404路由(匹配所有未定义路由)
  {
    path: '/:pathMatch(.*)*', // 匹配所有路径
    name: 'NotFound',
    component: () => import('@/views/NotFound.vue'),
    meta: { title: '页面不存在' }
  }
]

// 3. 创建路由实例
const router = createRouter({
  history: createWebHistory(), // 哈希模式用createWebHashHistory()
  routes // 传入路由规则
})

// 4. 导出路由
export default router

3. 全局注册路由(main.js)


// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router' // 引入路由

const app = createApp(App)
app.use(router) // 注册路由
app.mount('#app')

4. 配置路由出口(App.vue)


<template>
  &lt;div id="app"&gt;
    <!-- 路由导航(类似a标签) -->
    <nav>
      <router-link to="/">首页</router-link>
      <router-link to="/about">关于我们</router-link>
      <router-link :to="{ name: 'User', params: { id: 123 } }"&gt;用户中心&lt;/router-link&gt;
    &lt;/nav&gt;
    
    <!-- 路由出口:匹配的组件会渲染到这里 -->
    <router-view></router-view>
  </div>
</template>

二、核心用法(高频刚需)

1. 路由跳转(3种方式)


<script setup>
import { useRouter, useRoute } from 'vue-router'

// 实例化路由
const router = useRouter() // 用于跳转
const route = useRoute() // 用于获取路由信息

// 方式1:router-link(模板中使用,最简洁)
// <router-link to="/home">首页</router-link>

// 方式2:编程式跳转(JS中使用)
const goHome = () => {
  router.push('/home') // 直接传路径
  // 或传对象(推荐,可传参数)
  router.push({
    name: 'Home', // 用路由名称跳转(更稳定)
    query: { name: '手绘君' } // 拼接在url上的参数
  })
}

// 方式3:替换当前路由(不会留下历史记录,适合登录页)
const goLogin = () => {
  router.replace('/login')
}

// 后退/前进
const goBack = () => {
  router.back() // 后退
  // router.forward() // 前进
}
</script>

2. 路由传参(2种方式)

方式1:query传参(url拼接,类似get请求)

// 跳转页:传参
router.push({
  name: 'About',
  query: { id: 1, msg: '测试' }
})
// url会变成:/about?id=1&msg=测试

// 接收页:获取参数
const route = useRoute()
console.log(route.query.id) // 1
console.log(route.query.msg) // 测试
方式2:params传参(动态路由,url不拼接,类似post请求)
// 1. 先配置动态路由(router/index.js)
{
  path: '/user/:id', // :id是动态参数
  name: 'User',
  component: User
}

// 2. 跳转页:传参
router.push({
  name: 'User', // 必须用name跳转,不能用path
  params: { id: 123, name: '手绘君' }
})

// 3. 接收页:获取参数
const route = useRoute()
console.log(route.params.id) // 123
console.log(route.params.name) // 手绘君

3. 路由守卫(权限控制核心)

以全局前置守卫为例(登录拦截),其他守卫用法类似:

// router/index.js
router.beforeEach((to, from, next) => {
  // to:要跳转到的路由
  // from:从哪个路由跳转过来
  // next:放行/跳转的函数

  // 1. 设置页面标题
  document.title = to.meta.title || 'Vue3笔记'

  // 2. 登录拦截(只有登录才能访问/user路由)
  const isLogin = localStorage.getItem('isLogin') // 模拟登录状态
  if (to.name === 'User' && !isLogin) {
    // 未登录,跳转到登录页
    next('/login')
  } else {
    // 已登录/无需登录,放行
    next()
  }
})

💡 小总结:路由核心=配置路由+跳转+传参+守卫,掌握这4点,满足90%的项目需求!

📌 第四篇:Vue3 组件封装实战(新手也能学会)

组件封装是Vue3的核心优势,能大幅提高代码复用率、简化开发,这篇以3个高频组件(按钮、输入框、弹窗)为例,教你从零封装可复用组件!

一、封装核心原则

  • ✅ 单一职责:一个组件只做一件事(比如按钮组件只负责按钮展示和点击)

  • ✅ 可复用:通过props传参,适配不同场景

  • ✅ 可扩展:通过slot插槽,支持自定义内容

  • ✅ 易维护:代码简洁,注释清晰,逻辑分离

二、实战1:封装通用按钮组件(Btn.vue)


<!-- src/components/Btn.vue -->
<template>
  <button 
    class="custom-btn"
    :class="['btn-' + type, { 'btn-disabled': disabled }]"
    @click="handleClick"
    :disabled="disabled"
  &gt;
    <!-- 插槽:自定义按钮内容 -->
    <slot>默认按钮</slot>
  </button>
</template>

<script setup>
// 1. 接收父组件传参(props)
const props = defineProps({
  // 按钮类型(primary/success/danger/default)
  type: {
    type: String,
    default: 'default' // 默认值
  },
  // 是否禁用
  disabled: {
    type: Boolean,
    default: false
  }
})

// 2. 子传父:点击事件
const emit = defineEmits(['click'])
const handleClick = () => {
  // 禁用状态下不触发事件
  if (!props.disabled) {
    emit('click')
  }
}
</script>

<style scoped>
.custom-btn {
  padding: 6px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}
/* 不同类型按钮样式 */
.btn-default {
  background: #f5f5f5;
  color: #333;
}
.btn-primary {
  background: #42b983; /* Vue绿 */
  color: #fff;
}
.btn-success {
  background: #67c23a;
  color: #fff;
}
.btn-danger {
  background: #f56c6c;
  color: #fff;
}
/* 禁用样式 */
.btn-disabled {
  cursor: not-allowed;
  opacity: 0.6;
}
</style>

组件使用示例


<template>
  <div>
    <Btn @click="handleClick">默认按钮</Btn>
    <Btn type="primary" @click="handleClick">主要按钮</Btn>
    <Btn type="success" @click="handleClick">成功按钮</Btn>
    <Btn type="danger" disabled>禁用按钮</Btn>
    <!-- 插槽自定义内容 -->
    <Btn type="primary">
      <i class="icon">✓</i> 带图标按钮
    </Btn>
  </div>
</template>

<script setup>
import Btn from '@/components/Btn.vue'

const handleClick = () => {
  console.log('按钮被点击了')
}
</script>

三、实战2:封装通用输入框组件(Input.vue)


<!-- src/components/Input.vue -->
<template>
  &lt;div class="custom-input"&gt;
    <!-- 标签插槽 -->
    <label class="input-label" v-if="label">{{ label }}</label>
    <input
      class="input-content"
      :type="type"
      :placeholder="placeholder"
      :value="modelValue"
      @input="handleInput"
      :disabled="disabled"
    />
  </div>
</template>

<script setup>
// 1. 接收父组件传参
const props = defineProps({
  // 输入框类型(text/password/number)
  type: {
    type: String,
    default: 'text'
  },
  // 占位提示
  placeholder: {
    type: String,
    default: ''
  },
  // 绑定值(v-model)
  modelValue: {
    type: [String, Number],
    default: ''
  },
  // 标签文本
  label: {
    type: String,
    default: ''
  },
  // 是否禁用
  disabled: {
    type: Boolean,
    default: false
  }
})

// 2. 子传父:同步输入值(配合v-model使用)
const emit = defineEmits(['update:modelValue'])
const handleInput = (e) => {
  emit('update:modelValue', e.target.value)
}
</script>

<style scoped>
.custom-input {
  display: flex;
  align-items: center;
  margin: 10px 0;
}
.input-label {
  width: 80px;
  font-size: 14px;
  margin-right: 10px;
}
.input-content {
  padding: 6px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
  width: 200px;
}
.input-content:disabled {
  background: #f5f5f5;
  cursor: not-allowed;
}
</style>

组件使用示例(v-model绑定)


<template>
  <div>
    <Input 
      label="用户名" 
      placeholder="请输入用户名" 
      v-model="username"
    />
    <Input 
      label="密码" 
      type="password" 
      placeholder="请输入密码" 
      v-model="password"
    />
    <Input 
      label="手机号" 
      type="number" 
      placeholder="请输入手机号" 
      v-model="phone"
      disabled
    />
  </div>
</template>

<script setup>
import Input from '@/components/Input.vue'
import { ref } from 'vue'

const username = ref('')
const password = ref('')
const phone = ref('13800138000')
</script>

四、实战3:封装通用弹窗组件(Modal.vue)


<!-- src/components/Modal.vue -->
<template>
  <div class="modal-mask" v-if="visible" @click="handleMaskClick">
    &lt;div class="modal-content" @click.stop&gt;
      <!-- 弹窗标题 -->
      <div class="modal-header">
        <h3 class="modal-title">{{ title }}</h3>
        <button class="modal-close" @click="handleClose">×</button>
      </div>
      <!-- 弹窗内容(插槽,自定义) -->
      <div class="modal-body">
        <slot>默认弹窗内容</slot>
      </div&gt;
      <!-- 弹窗底部(插槽,自定义按钮) -->
      <div class="modal-footer">
        <slot name="footer">
          <Btn @click="handleClose">取消</Btn>
          <Btn type="primary" @click="handleConfirm">确认</Btn>
        </slot>
      </div>
    </div>
  </div>
</template>

<script setup>
import Btn from './Btn.vue' // 引入之前封装的按钮组件

// 接收父组件传参
const props = defineProps({
  // 是否显示弹窗
  visible: {
    type: Boolean,
    default: false
  },
  // 弹窗标题
  title: {
    type: String,
    default: '提示'
  },
  // 点击遮罩是否关闭
  maskClose: {
    type: Boolean,
    default: true
  }
})

// 子传父:关闭、确认事件
const emit = defineEmits(['close', 'confirm'])
// 关闭弹窗
const handleClose = () => {
  emit('close')
}
// 确认按钮
const handleConfirm = () => {
  emit('confirm')
}
// 点击遮罩关闭
const handleMaskClick = () => {
  if (props.maskClose) {
    emit('close')
  }
}
</script>

<style scoped>
/* 遮罩层 */
.modal-mask {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 999;
}
/* 弹窗内容 */
.modal-content {
  width: 400px;
  background: #fff;
  border-radius: 8px;
  overflow: hidden;
}
/* 弹窗标题 */
.modal-header {
  padding: 16px;
  border-bottom: 1px solid #eee;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.modal-title {
  font-size: 16px;
  margin: 0;
}
.modal-close {
  border: none;
  background: none;
  font-size: 20px;
  cursor: pointer;
  color: #999;
}
/* 弹窗内容 */
.modal-body {
  padding: 20px;
  font-size: 14px;
}
/* 弹窗底部 */
.modal-footer {
  padding: 16px;
  border-top: 1px solid #eee;
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}
</style>

组件使用示例


<template>
  <div>
    <Btn type="primary" @click="showModal = true">打开弹窗</Btn>
    
    <Modal
      :visible="showModal"
      title="提示弹窗"
      :mask-close="false"
      @close="showModal = false"
      @confirm="handleConfirm"
    >
      <!-- 自定义弹窗内容 -->
      <p>确定要执行这个操作吗?</p>
      <Input placeholder="请输入备注" v-model="remark" />
      <!-- 自定义底部按钮(覆盖默认) -->
      <template #footer>
        <Btn @click="showModal = false">取消</Btn>
        <Btn type="danger" @click="handleDelete">删除</Btn>
        <Btn type="primary" @click="handleConfirm">确认</Btn>
      </template>
    </Modal>
  </div>
</template>

<script setup>
import Modal from '@/components/Modal.vue'
import Input from '@/components/Input.vue'
import Btn from '@/components/Btn.vue'
import { ref } from 'vue'

const showModal = ref(false)
const remark = ref('')

const handleConfirm = () => {
  console.log('确认操作,备注:', remark.value)
  showModal.value = false
}

const handleDelete = () => {
  console.log('执行删除操作')
  showModal.value = false
}
</script>

💡 小总结:组件封装=props传参+slot插槽+emit传事件,掌握这三点,能封装任何你需要的组件!

📌 第五篇:Vue3+Vite 优化实战(打包提速+页面优化)

Vue3+Vite 本身就比Vue2+Webpack快,但项目变大后仍会出现打包慢、页面卡顿,这篇汇总5个高频优化技巧,新手也能轻松操作,直接提升项目性能!

一、Vite 基础优化(打包提速)

1. 优化依赖预构建

Vite会预构建依赖,减少打包时间,修改vite.config.js配置,指定预构建的依赖:


// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  // 优化依赖预构建
  optimizeDeps: {
    // 强制预构建的依赖(高频使用的第三方库)
    include: ['vue', 'vue-router', 'pinia', 'axios'],
    // 排除不需要预构建的依赖
    exclude: ['lodash-es']
  }
})

2. 打包压缩优化(减小体积)

安装压缩插件,打包时自动压缩JS/CSS/HTML,减小文件体积,提升加载速度:


# 安装压缩插件
npm install vite-plugin-compression --save-dev

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import compression from 'vite-plugin-compression'

export default defineConfig({
  plugins: [
    vue(),
    // 打包压缩(gzip格式)
    compression({
      algorithm: 'gzip', // 压缩算法
      threshold: 10240, // 超过10KB的文件才压缩
      deleteOriginFile: false // 不删除原文件
    })
  ]
})

二、代码层面优化(页面提速)

1. 组件懒加载(路由懒加载)

之前路由配置中提到的懒加载,是最核心的优化技巧,减少首页加载时间:


// router/index.js
// ❌ 错误:全部引入,首页加载慢
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'

// ✅ 正确:懒加载,只有访问时才加载组件
const Home = () => import('@/views/Home.vue')
const About = () => import('@/views/About.vue')

// 进阶:分块打包(相同模块打包到一个文件)
const User = () => import(/* webpackChunkName: "user" */ '@/views/User.vue')
const UserInfo = () => import(/* webpackChunkName: "user" */ '@/views/UserInfo.vue')

2. 图片优化(减小图片体积)

Vite自带图片处理,配合插件优化图片,支持webp格式(体积更小):


# 安装图片优化插件
npm install vite-plugin-imagemin --save-dev

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import imagemin from 'vite-plugin-imagemin'

export default defineConfig({
  plugins: [
    vue(),
    // 图片优化
    imagemin({
      gifsicle: {
        optimizationLevel: 7, // gif优化等级
        interlaced: false
      },
      optipng: {
        optimizationLevel: 7 // png优化等级
      },
      mozjpeg: {
        quality: 80 // jpg质量(0-100)
      },
      pngquant: {
        quality: [0.8, 0.9], // png质量范围
        speed: 4
      },
      svgo: {
        plugins: [
          { name: 'removeViewBox' },
          { name: 'removeEmptyAttrs', active: false }
        ]
      }
    })
  ],
  // 配置图片路径,优化加载
  assetsInclude: ['**/*.png', '**/*.jpg', '**/*.webp'],
  build: {
    assetsInlineLimit: 4096 // 小于4KB的图片转base64,减少请求
  }
})

3. 减少不必要的响应式数据

Vue3的响应式会消耗性能,非响应式数据不用ref/reactive包裹:


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

// ✅ 正确:非响应式数据,直接定义(不会变化的数据)
const staticData = {
  name: '手绘君',
  age: 20
}

// ✅ 正确:响应式数据,用ref/reactive(会变化的数据)
const count = ref(0)
const user = reactive({
  name: '测试君'
})
</script>

4. 虚拟列表(长列表优化)

当列表数据超过1000条时,会出现卡顿,用虚拟列表只渲染可视区域的内容:


# 安装虚拟列表插件(vue3专用)
npm install vue-virtual-scroller@next --save

<template>
  <!-- 虚拟列表组件 -->
  <RecycleScroller
    class="scroller"
    :items="longList" // 长列表数据
    :item-size="50" // 每个列表项的高度
    key-field="id" // 唯一标识字段
  >
    <template #default="{ item }">
      <div class="list-item">{{ item.name }}</div>
    </template>
  </RecycleScroller>
</template>

<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

// 模拟10000条长列表数据
const longList = Array.from({ length: 10000 }, (_, i) => ({
  id: i + 1,
  name: `列表项 ${i + 1}`
}))
</script>

<style scoped>
.scroller {
  height: 500px; // 必须设置固定高度
  width: 100%;
}
.list-item {
  height: 50px; // 和item-size一致
  line-height: 50px;
  border-bottom: 1px solid #eee;
}
</style>

三、优化总结

  • 🚀 打包优化:依赖预构建+压缩+图片优化,减小体积、提升打包速度

  • 🚀 页面优化:路由懒加载+虚拟列表,减少首页加载时间、解决长列表卡顿

  • 🚀 代码优化:减少不必要的响应式数据,降低性能消耗

💡 小技巧:优化后可以用 npm run build 查看打包体积,用浏览器开发者工具(Network)查看加载速度!

✏️ 合集持续更新ing,每篇都是爆款实用向,有错欢迎指出来~ 收藏起来,学Vue3不迷路!

(注:文档部分内容可能由 AI 生成)

聊一聊JS异步编程的前世今生

作者 兆子龙
2026年2月10日 16:42

JavaScript 异步编程进化史:从回调地狱到 async/await

前言

如果你问一个前端初学者:"JavaScript 最让你头疼的是什么?",十有八九会听到"异步编程"这个答案。从回调地狱到 Promise 链,再到如今优雅的 async/await,JavaScript 的异步编程经历了一场漫长的进化。今天,我们就来聊聊这段充满血泪的历史。

远古时期:同步的世界(1995-2009)

历史背景

1995 年,Brendan Eich 在 Netscape 公司用 10 天时间创造了 JavaScript(最初叫 LiveScript)。当时的设计初衷非常简单:为浏览器提供简单的页面交互能力,比如表单验证、按钮点击响应等。

那个年代,网页还很简单:

<!-- 1995 年的网页长这样 -->
<form onsubmit="return validateForm()">
  <input type="text" name="username" />
  <button type="submit">提交</button>
</form>

<script>
function validateForm() {
  var username = document.forms[0].username.value;
  if (username === '') {
    alert('用户名不能为空!');
    return false;
  }
  return true;
}
</script>

这个时期的 JavaScript 只需要处理简单的同步操作:

// 计算
var result = 1 + 2;

// DOM 操作
document.getElementById('btn').onclick = function() {
  alert('你点击了按钮');
};

// 表单验证
function validate(value) {
  return value.length > 0;
}

为什么只有同步?

因为当时的网页交互非常简单,不需要复杂的异步操作。即使有网络请求,也是通过表单提交刷新整个页面来完成的。

转折点:AJAX 的诞生

2005 年,Google 推出了 Gmail 和 Google Maps,展示了 AJAX(Asynchronous JavaScript and XML)的强大能力。突然间,网页可以在不刷新的情况下与服务器通信了!

这标志着 JavaScript 正式进入异步时代。

Callback 时期:回调地狱的噩梦(2005-2015)

标志性事件

  • 2005 年:AJAX 技术被广泛应用
  • 2009 年:Node.js 诞生,JavaScript 进入服务端,异步 I/O 成为核心
  • 2010 年:回调函数成为异步编程的主流模式

解决的问题

回调函数让 JavaScript 能够处理异步操作,不会阻塞主线程:

// 发起网络请求
function getUserData(userId, callback) {
  var xhr = new XMLHttpRequest();
  xhr.open('GET', '/api/user/' + userId);
  
  xhr.onload = function() {
    if (xhr.status === 200) {
      callback(null, JSON.parse(xhr.responseText));
    } else {
      callback(new Error('请求失败'));
    }
  };
  
  xhr.send();
}

// 使用
getUserData(123, function(err, user) {
  if (err) {
    console.error(err);
    return;
  }
  console.log('用户信息:', user);
});

新的矛盾:回调地狱(Callback Hell)

当你需要执行多个依赖的异步操作时,代码会变成这样:

// 😱 真实的回调地狱代码
getUserData(123, function(err, user) {
  if (err) {
    console.error('获取用户失败:', err);
    return;
  }
  
  // 获取用户的订单列表
  getOrders(user.id, function(err, orders) {
    if (err) {
      console.error('获取订单失败:', err);
      return;
    }
    
    // 获取第一个订单的详情
    getOrderDetail(orders[0].id, function(err, detail) {
      if (err) {
        console.error('获取订单详情失败:', err);
        return;
      }
      
      // 获取订单中的商品信息
      getProducts(detail.productIds, function(err, products) {
        if (err) {
          console.error('获取商品失败:', err);
          return;
        }
        
        // 计算总价
        calculateTotal(products, function(err, total) {
          if (err) {
            console.error('计算总价失败:', err);
            return;
          }
          
          // 终于可以显示结果了!
          console.log('订单总价:', total);
        });
      });
    });
  });
});

回调地狱的痛点

  1. 代码横向发展:嵌套层级越来越深,形成"金字塔"结构
  2. 错误处理重复:每一层都要写 if (err) 判断
  3. 可读性极差:很难理解代码的执行流程
  4. 难以维护:修改一个环节可能影响整个调用链
  5. 调试困难:错误堆栈信息混乱

再看一个 Node.js 的例子:

// 😱 Node.js 文件操作的回调地狱
fs.readFile('config.json', 'utf8', function(err, config) {
  if (err) throw err;
  
  var parsedConfig = JSON.parse(config);
  
  fs.readFile(parsedConfig.dataFile, 'utf8', function(err, data) {
    if (err) throw err;
    
    var processedData = processData(data);
    
    fs.writeFile('output.json', JSON.stringify(processedData), function(err) {
      if (err) throw err;
      
      fs.readFile('output.json', 'utf8', function(err, result) {
        if (err) throw err;
        
        console.log('处理完成:', result);
      });
    });
  });
});

社区开始意识到:必须找到更好的方式来处理异步代码!

Promise 时期:链式调用的曙光(2012-2017)

标志性事件

  • 2012 年:Promise/A+ 规范发布
  • 2015 年:ES6 正式将 Promise 纳入标准
  • 2015 年:各大浏览器开始原生支持 Promise

解决的问题

Promise 通过链式调用解决了回调地狱的嵌套问题:

// ✅ 使用 Promise 改写
getUserData(123)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetail(orders[0].id))
  .then(detail => getProducts(detail.productIds))
  .then(products => calculateTotal(products))
  .then(total => {
    console.log('订单总价:', total);
  })
  .catch(err => {
    console.error('出错了:', err);
  });

Promise 的优势

  1. 扁平化:不再横向嵌套,而是纵向链式调用
  2. 统一错误处理:一个 .catch() 捕获所有错误
  3. 状态管理:pending、fulfilled、rejected 三种状态清晰
  4. 可组合Promise.all()Promise.race() 等工具方法

新的矛盾:Promise 链的三角形代码

虽然 Promise 解决了回调地狱,但在复杂场景下,仍然会出现新的问题:

// 😱 Promise 的三角形代码
function processUserOrder(userId) {
  return getUserData(userId)
    .then(user => {
      return getOrders(user.id)
        .then(orders => {
          return getOrderDetail(orders[0].id)
            .then(detail => {
              return getProducts(detail.productIds)
                .then(products => {
                  // 这里需要同时访问 user、orders、detail、products
                  return {
                    user: user,
                    orders: orders,
                    detail: detail,
                    products: products
                  };
                });
            });
        });
    })
    .then(result => {
      console.log('用户:', result.user.name);
      console.log('订单数:', result.orders.length);
      console.log('商品:', result.products);
    });
}

问题分析

当你需要在后续步骤中访问前面的变量时,不得不:

  1. 要么嵌套 Promise(又回到了嵌套地狱)
  2. 要么在外层定义变量(污染作用域)
// 😱 方案1:嵌套 Promise(又回到地狱)
getUserData(userId)
  .then(user => {
    return getOrders(user.id)
      .then(orders => {
        return getOrderDetail(orders[0].id)
          .then(detail => {
            // 可以访问 user、orders、detail
            return processData(user, orders, detail);
          });
      });
  });

// 😱 方案2:污染外层作用域
let user, orders, detail;

getUserData(userId)
  .then(u => {
    user = u;
    return getOrders(user.id);
  })
  .then(o => {
    orders = o;
    return getOrderDetail(orders[0].id);
  })
  .then(d => {
    detail = d;
    // 现在可以访问 user、orders、detail
    return processData(user, orders, detail);
  });

其他痛点

// 😱 条件分支变得复杂
getUserData(userId)
  .then(user => {
    if (user.isVip) {
      return getVipOrders(user.id)
        .then(orders => {
          return { user, orders, isVip: true };
        });
    } else {
      return getNormalOrders(user.id)
        .then(orders => {
          return { user, orders, isVip: false };
        });
    }
  })
  .then(result => {
    // 处理结果...
  });

// 😱 循环中的 Promise
function processItems(items) {
  let promise = Promise.resolve();
  
  items.forEach(item => {
    promise = promise.then(() => {
      return processItem(item);
    });
  });
  
  return promise;
}

社区再次呼唤:能不能像写同步代码一样写异步?

Async/Await 时期:异步编程的终极形态(2017-至今)

标志性事件

  • 2017 年:ES8(ES2017)正式引入 async/await
  • 2017 年:Node.js 7.6+ 原生支持 async/await
  • 2018 年:主流浏览器全面支持

解决的问题

async/await 让异步代码看起来像同步代码:

// ✅ 使用 async/await 改写
async function processUserOrder(userId) {
  try {
    const user = await getUserData(userId);
    const orders = await getOrders(user.id);
    const detail = await getOrderDetail(orders[0].id);
    const products = await getProducts(detail.productIds);
    const total = await calculateTotal(products);
    
    console.log('订单总价:', total);
    
    // 可以轻松访问所有变量
    console.log('用户:', user.name);
    console.log('订单数:', orders.length);
    console.log('商品:', products);
    
  } catch (err) {
    console.error('出错了:', err);
  }
}

对比三个时代的代码

// 😱 Callback 版本
getUserData(123, function(err, user) {
  if (err) return console.error(err);
  
  getOrders(user.id, function(err, orders) {
    if (err) return console.error(err);
    
    console.log('用户:', user.name);
    console.log('订单数:', orders.length);
  });
});

// 😐 Promise 版本
let user;
getUserData(123)
  .then(u => {
    user = u;
    return getOrders(user.id);
  })
  .then(orders => {
    console.log('用户:', user.name);
    console.log('订单数:', orders.length);
  })
  .catch(err => console.error(err));

// ✅ Async/Await 版本
async function process() {
  try {
    const user = await getUserData(123);
    const orders = await getOrders(user.id);
    
    console.log('用户:', user.name);
    console.log('订单数:', orders.length);
  } catch (err) {
    console.error(err);
  }
}

Async/Await 的优势

1. 代码可读性极高
// ✅ 像写同步代码一样清晰
async function checkout() {
  const cart = await getCart();
  const address = await getAddress();
  const payment = await processPayment(cart.total);
  const order = await createOrder(cart, address, payment);
  
  return order;
}
2. 错误处理更自然
// ✅ 使用熟悉的 try-catch
async function fetchData() {
  try {
    const data = await fetch('/api/data');
    const json = await data.json();
    return json;
  } catch (err) {
    console.error('请求失败:', err);
    throw err;
  }
}
3. 条件分支更简洁
// ✅ 条件判断很自然
async function processUser(userId) {
  const user = await getUserData(userId);
  
  if (user.isVip) {
    const vipOrders = await getVipOrders(user.id);
    return processVipOrders(vipOrders);
  } else {
    const normalOrders = await getNormalOrders(user.id);
    return processNormalOrders(normalOrders);
  }
}
4. 循环处理更直观
// ✅ 顺序处理
async function processItems(items) {
  for (const item of items) {
    await processItem(item);
  }
}

// ✅ 并行处理
async function processItemsParallel(items) {
  await Promise.all(items.map(item => processItem(item)));
}
5. 调试体验更好
// ✅ 可以直接打断点,查看变量
async function debug() {
  const user = await getUserData(123);
  debugger; // 可以在这里查看 user
  
  const orders = await getOrders(user.id);
  debugger; // 可以在这里查看 orders
  
  return orders;
}

实战案例

案例 1:文件处理
// Callback 版本 😱
fs.readFile('input.txt', 'utf8', function(err, data) {
  if (err) throw err;
  
  const processed = processData(data);
  
  fs.writeFile('output.txt', processed, function(err) {
    if (err) throw err;
    
    fs.readFile('output.txt', 'utf8', function(err, result) {
      if (err) throw err;
      console.log('完成:', result);
    });
  });
});

// Async/Await 版本 ✅
async function processFile() {
  try {
    const data = await fs.promises.readFile('input.txt', 'utf8');
    const processed = processData(data);
    await fs.promises.writeFile('output.txt', processed);
    const result = await fs.promises.readFile('output.txt', 'utf8');
    console.log('完成:', result);
  } catch (err) {
    console.error('出错:', err);
  }
}
案例 2:并发请求
// Promise 版本 😐
Promise.all([
  fetch('/api/user'),
  fetch('/api/orders'),
  fetch('/api/products')
])
  .then(responses => {
    return Promise.all(responses.map(r => r.json()));
  })
  .then(([user, orders, products]) => {
    console.log(user, orders, products);
  });

// Async/Await 版本 ✅
async function fetchAll() {
  const [user, orders, products] = await Promise.all([
    fetch('/api/user').then(r => r.json()),
    fetch('/api/orders').then(r => r.json()),
    fetch('/api/products').then(r => r.json())
  ]);
  
  console.log(user, orders, products);
}
案例 3:错误重试
// ✅ 实现带重试的请求
async function fetchWithRetry(url, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url);
      return await response.json();
    } catch (err) {
      if (i === maxRetries - 1) throw err;
      console.log(`重试 ${i + 1}/${maxRetries}`);
      await sleep(1000 * (i + 1)); // 指数退避
    }
  }
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

常见陷阱与最佳实践

陷阱 1:忘记 await
// ❌ 错误:忘记 await
async function bad() {
  const data = fetchData(); // 返回 Promise,不是数据!
  console.log(data); // Promise { <pending> }
}

// ✅ 正确
async function good() {
  const data = await fetchData();
  console.log(data); // 实际数据
}
陷阱 2:串行执行导致性能问题
// ❌ 错误:串行执行,耗时 3 秒
async function slow() {
  const user = await fetchUser();      // 1 秒
  const orders = await fetchOrders();  // 1 秒
  const products = await fetchProducts(); // 1 秒
  return { user, orders, products };
}

// ✅ 正确:并行执行,耗时 1 秒
async function fast() {
  const [user, orders, products] = await Promise.all([
    fetchUser(),
    fetchOrders(),
    fetchProducts()
  ]);
  return { user, orders, products };
}
陷阱 3:循环中的 await
// ❌ 错误:串行处理,很慢
async function processItemsSlow(items) {
  const results = [];
  for (const item of items) {
    results.push(await processItem(item)); // 一个一个处理
  }
  return results;
}

// ✅ 正确:并行处理,快速
async function processItemsFast(items) {
  return await Promise.all(items.map(item => processItem(item)));
}
陷阱 4:错误处理不当
// ❌ 错误:错误被吞掉
async function bad() {
  await fetchData(); // 如果出错,错误会被忽略
}

// ✅ 正确:捕获错误
async function good() {
  try {
    await fetchData();
  } catch (err) {
    console.error('出错:', err);
    throw err; // 或者处理错误
  }
}

进化史总结

时期 时间 特点 优点 缺点
同步时期 1995-2005 只有同步代码 简单直观 无法处理异步
Callback 2005-2015 回调函数 能处理异步 回调地狱、错误处理繁琐
Promise 2012-2017 链式调用 扁平化、统一错误处理 三角形代码、变量作用域问题
Async/Await 2017-至今 同步风格写异步 可读性强、易调试、易维护 需要注意性能陷阱

未来展望

虽然 async/await 已经很完美,但 JavaScript 的异步编程仍在进化:

1. Top-level await(ES2022)

// ✅ 模块顶层直接使用 await
const data = await fetch('/api/data');
export default data;

2. AsyncIterator 和 for-await-of

// ✅ 异步迭代器
async function* generateData() {
  for (let i = 0; i < 10; i++) {
    await sleep(100);
    yield i;
  }
}

for await (const num of generateData()) {
  console.log(num);
}

3. Promise.allSettled / Promise.any

// ✅ 等待所有 Promise 完成(无论成功失败)
const results = await Promise.allSettled([
  fetch('/api/1'),
  fetch('/api/2'),
  fetch('/api/3')
]);

// ✅ 返回第一个成功的 Promise
const fastest = await Promise.any([
  fetch('/api/1'),
  fetch('/api/2'),
  fetch('/api/3')
]);

总结

从回调地狱到 async/await,JavaScript 的异步编程经历了三次重大进化:

  1. Callback:解决了异步问题,但带来了回调地狱
  2. Promise:解决了回调地狱,但带来了三角形代码
  3. Async/Await:让异步代码像同步代码一样优雅

如今,async/await 已经成为 JavaScript 异步编程的事实标准。它不仅解决了前辈们的问题,还提供了极佳的开发体验。

最佳实践建议

  • ✅ 优先使用 async/await
  • ✅ 注意并行执行优化性能
  • ✅ 使用 try-catch 处理错误
  • ✅ 理解 Promise 的底层原理
  • ✅ 善用 Promise.all/race/allSettled/any

异步编程的进化史告诉我们:好的语言特性不是一蹴而就的,而是在不断解决实际问题中逐步完善的


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

参考资料

从 0-1 轻松学会 Vue3 Composables(组合式函数),告别臃肿代码,做会封装的优雅前端

作者 SuperEugene
2026年2月10日 16:18

ps.本文中的第八条包含讲解所用到的所有代码。

一、先忘掉已知编码“模式”,想一个真实问题

假设现在要写一个人员列表页

  • 上面有搜索框(姓名、账号、手机号)
  • 中间一个表格(数据 + 分页)
  • 每一行有:编辑、分配角色、改密码、删除
  • 点编辑/改密码/分配角色会弹出对话框

如果全写在一个 .vue 文件里,会怎样?

  • <template> 还好,主要是布局
  • <script> 里会堆满:搜索表单数据、表格数据、分页、好几个弹窗的显示/隐藏、每个按钮的点击函数、每个弹窗的确认/关闭……

一个文件动不动就 500 行、几十个变量和函数,改一处要翻半天,也不好复用

所以我们要解决的是两件事:

  1. 把“逻辑”从“页面”里拆出来,让页面只负责“长什么样、点哪里”
  2. 拆出来的逻辑要能复用,比如别的页面也要“列表+分页+弹窗”时可以直接用

这种「逻辑从页面里抽出去、按功能组织、可复用」的写法,在 Vue 3 里就对应两样东西:

  • 组合式 API:用 refreactiveonMounted 等写逻辑的方式
  • 组合式函数(Composables) - 音标:/kəm'pəuzəblz/:把一段逻辑封装成一个“以 use 开头的函数”,在页面里调一下就能用

下面分步讲。

二、第一步:认识“组合式 API”(在页面里写逻辑)

以前 Vue 2 常见的是「选项式 API」:一个组件里分好几块 —— datamethodsmounted 等,逻辑按“类型”分,而不是按“功能”分。

Vue 3 的组合式 API 换了一种思路:setup(或 <script setup>)里,像写普通 JS 一样,用变量和函数把“和某块功能相关的所有东西”写在一起

例如“搜索”这一块功能,可以这样写在一起:

// 和“搜索”相关的都放一起
const searchForm = reactive({ userName: '', userAccount: '' })
const handleSearch = () => { /* 调用接口、刷新列表 */ }
const handleReset = () => { searchForm.userName = ''; ... }

“分页”又是一块:

const pagination = reactive({ currentPage: 1, pageSize: 10, total: 0 })
const handleSizeChange = (val) => { ... }
const handleCurrentChange = (val) => { ... }

这样写,同一个功能的数据和函数挨在一起,读起来是“一块一块”的,而不是 data 一堆、methods 又一堆。这就是“组合式”的意思:按逻辑块组合,而不是按选项类型分。

用到的两个基础工具要知道:

  • ref(值):存“一个会变的值”,用的时候要 .value;在模板里可以省略 .value
  • reactive(对象):存“一组会变的属性”,用的时候直接 .属性名 就行

到这里,你只需要记住:<script setup> 里用 ref/reactive + 函数,把同一块功能的逻辑写在一起,这就是“组合式 API”的用法。

三、第二步:逻辑太多时,把“一整块”搬出去

当这一页的逻辑越来越多(搜索、表格、分页、编辑弹窗、改密码弹窗、角色弹窗……),<script setup> 里会变得很长。下一步很自然:把“一整块逻辑”原样搬到一个单独的 .ts 文件里

做法就三步:

  1. 新建一个文件,比如 usePersonnelList.ts
  2. 在里面写一个函数,函数名按约定用 use 开头,比如 usePersonnelList
  3. 把原来在页面里的那一大坨(ref、reactive、所有 handleXxx)剪过去,放进这个函数里,最后 return 出页面需要用的东西

例如:

// usePersonnelList.ts
import { ref, reactive } from 'vue'

export function usePersonnelList() {
  const searchForm = reactive({ userName: '', userAccount: '' })
  const tableData = reactive([])
  const handleSearch = () => { ... }
  const handleReset = () => { ... }
  // ... 其他状态和方法

  return {
    searchForm,
    tableData,
    handleSearch,
    handleReset,
    // 页面要用啥就 return 啥
  }
}

页面里就只做一件事:调用这个函数,把 return 出来的东西拿来用

<script setup>
import { usePersonnelList } from './composables/usePersonnelList'

const {
  searchForm,
  tableData,
  handleSearch,
  handleReset,
} = usePersonnelList()
</script>

<template>
  <!-- 用 searchForm、tableData,绑定 handleSearch、handleReset -->
</template>

这种“以 use 开头、封装一块有状态逻辑、return 给组件用”的函数,官方名字就叫「组合式函数」(Composable,英文文档里会看到这个词)。

当前看到的「编码模式」核心就是:页面只负责布局和调用 useXxx(),具体逻辑都在 useXxx 里

可能有的同学看到 状态 这个词的时候不能理解,不能理解的同学我想应该同样也想不明白vuexpinia为什么叫状态管理而不叫变量管理或者常量管理或者容器管理。可以理解的同学可直接看下一步,接下来的小内容则是给不能理解的同学补补课。

讲解:首先状态和变量一样,都是存储数据的容器。区别在于状态和 UI 是 “双向绑定” 的,变量不一定。普通 JS 变量(比如 let a = 1)改了就是改了,页面不会有任何反应;但 Vue 的状态(比如 const a = ref(1))改 a.value = 2 时,页面里用到 a 的地方会自动更新 —— 这是 “状态” 最核心的特征:状态是 “活的”,和 UI 联动

简单粗暴:

  • 所以不理解的同学可以简单粗暴的将状态理解为可以引动UI变化的变量就是状态。 新手同学理解到这里就可以了,至于状态更精准的理解感兴趣的同学可以自行搜索学习。

不用过多的纠结,可以理解这个简单粗暴的定义就足够你看懂后面的讲解了。

四、用一句话串起来

  • 组合式 API:在 script 里用 ref/reactive + 函数,按“功能块”写逻辑。
  • 组合式函数:把某一整块逻辑搬进 useXxx(),页面里 const { ... } = useXxx() 拿来用。

所以:
“组合式 API”是说“怎么写逻辑”;“组合式函数”是说“把写好的逻辑封装成 useXxx,方便复用和组织”。
当前人员模块的写法,就是:用组合式 API 在 usePersonnelList 里写逻辑,在 index.vue 里只调用 usePersonnelList(),这就是官方主推的这种模式。


五、和当前示例对上号

现在的结构可以这样理解:

当前看到的 含义(小白版)
index.vue 里只有 template + 一个 usePersonnelList() 页面只负责“长什么样”和“用哪一块逻辑”
composables/usePersonnelList.ts 人员列表这一页的“所有逻辑”都在这一个函数里
components/PersonnelSearchForm.vue 把表格、弹窗拆成小组件,只负责展示和发事件
types.ts 把共用的类型(Personnel、Role、表单类型等)集中放,方便复用和改

数据流可以简单理解成:

  1. usePersonnelList() 提供:searchFormtableDatahandleSearchhandleEdit……
  2. index.vue 把这些绑到模板和子组件上(:search-form="searchForm"@search="handleSearch"
  3. 子组件只通过 props 拿数据、通过 emit 触发事件,真正的状态和请求都在 composable 里

这样就实现了:逻辑在 useXxx,页面和组件只做“接线”

六、什么时候用、怎么用(实用口诀)

  • 一个页面逻辑很多 → 先在同一文件里用组合式 API 按“功能块”写;还觉得乱,再抽成 useXxx
  • 多个页面要用同一套逻辑 → 直接写成 useXxx,在不同页面里 const { ... } = useXxx() 即可
  • 命名:这类函数统一用 use 开头,如 usePersonnelListuseMouseuseFetch
  • 文件放哪:和当前功能强相关的就放当前模块下,例如 personnel/composables/usePersonnelList.ts;全项目都要用的可以放 src/composables/ 之类

七、小结(真正从 0 到 1 的路线)

  1. 问题:页面逻辑一多就难维护、难复用。
  2. 组合式 API:用 ref/reactive + 函数,在 script 里按“功能块”组织逻辑。
  3. 组合式函数:把一整块逻辑放进 useXxx(),return 出状态和方法,页面里解构使用。
  4. 现在的模式index.vue 薄薄一层 + usePersonnelList 一坨逻辑 + 几个子组件 + types.ts,这就是 Vue 3 官方在「可复用性 → 组合式函数」里主推的写法。

八、示例代码

想看看实际运行起来什么样的同学也可自行新建一个vue3+ts的项目,复制粘贴代码到编辑器中运行起来看看。我在写这个示例代码时候所创建的项目环境:

  • node版本20.19.0
  • 使用到Element Plus组件库

我在配置代码的时候会习惯性的配置组件的自动引入,所以在代码中无需再手动引入使用到的组件,没有配置过自动引入的同学不要忘记自己补上组件的引入哦。如果在创建项目复制示例代码遇到环境问题的情况下可尝试通过对比我的开发环境解决问题,希望可以有所帮助。

  1. 结构简要说明
src/views/personnel/
├── index.vue                          # 页面入口:标题、搜索、表格、分页、弹窗挂载
├── types.ts                           # 类型定义(如 PersonnelSearchFormPersonnelEditFormRole 等)
├── composables/
│   └── usePersonnelList.ts           # 列表逻辑:搜索、分页、增删改、分配角色、改密等
└── components/
    ├── PersonnelSearchForm.vue       # 顶部搜索栏(用户名称 / 帐号 / 电话)
    ├── PersonnelEditDialog.vue       # 新增/编辑用户弹窗
    ├── PersonnelPasswordDialog.vue    # 修改密码弹窗
    └── PersonnelRoleAssignDialog.vue # 分配角色弹窗
文件 作用
index.vue 主页面,引入搜索表单、表格、分页和三个弹窗,并承接 usePersonnelList 的状态与方法。
types.ts 定义该模块用到的 TS 类型/接口。
usePersonnelList.ts 组合式函数:搜索表单、表格数据、分页、弹窗显隐、请求与事件处理(搜索/重置/增删改/分配角色/改密等)。
PersonnelSearchForm.vue 仅负责搜索表单 UI 与「搜索 / 重置」事件。
PersonnelEditDialog.vue 新增/编辑用户的表单弹窗。
PersonnelPasswordDialog.vue 修改密码的单表单项弹窗。
PersonnelRoleAssignDialog.vue 角色多选表格弹窗,用于分配角色。

数据与业务集中在 usePersonnelList.ts,页面与组件主要负责布局和调用该 composable。

  1. 运行后的项目展示

Snipaste_2026-02-10_14-10-38.png

Snipaste_2026-02-10_14-11-29.png

Snipaste_2026-02-10_14-11-41.png

Snipaste_2026-02-10_14-11-53.png

Snipaste_2026-02-10_14-12-07.png

  1. 可复制运行的代码

下面代码与前面章节一一对应:第二节的「按功能块写」体现在 usePersonnelList.ts 里搜索、分页、弹窗等逻辑块;第三节的「搬进 useXxx、return 给页面用」就是 usePersonnelList() 和其 return;第四节的数据流对应 index.vue 里解构 usePersonnelList() 并绑到模板和子组件。阅读时可按「概念 → 对应文件」对照看。

index.vue

<template>
  <div class="personnel-management">
    <!-- 页面标题 -->
    <div class="page-header">
      <div class="page-header-inner">
        <span class="page-title-accent" />
        <div>
          <h1 class="page-title">人员管理</h1>
          <p class="page-desc">管理系统用户与权限,一目了然</p>
        </div>
      </div>
    </div>

    <!-- 搜索表单 -->
    <PersonnelSearchForm
      :search-form="searchForm"
      @search="handleSearch"
      @reset="handleReset"
    />

    <!-- 数据表格 -->
    <div class="table-section">
      <div class="table-toolbar">
        <el-button type="primary" class="btn-add" @click="handleAdd">
          <span class="btn-add-icon">+</span>
          新增人员
        </el-button>
      </div>
      <el-table
        :data="tableData"
        class="personnel-table"
        style="width: 100%"
        :row-key="(row) => row.id"
        :header-cell-style="headerCellStyle"
        :row-class-name="tableRowClassName"
      >
        <el-table-column label="头像" width="96" align="center">
          <template #default="{ row }">
            <div class="avatar-wrap">
              <el-avatar :src="row.avatar" :size="44" class="user-avatar" />
            </div>
          </template>
        </el-table-column>

        <el-table-column prop="userName" label="用户名称" align="center" min-width="100" />
        <el-table-column prop="position" label="职位" align="center" min-width="100" />
        <el-table-column prop="userAccount" label="用户账号" align="center" min-width="120" />
        <el-table-column prop="userPhone" label="用户电话" align="center" min-width="120" />
        <el-table-column prop="userEmail" label="用户邮箱" align="center" min-width="160" />

        <el-table-column label="操作" width="340" fixed="right" align="center">
          <template #default="{ row }">
            <div class="table-actions">
              <el-button class="action-btn action-btn--primary" size="small" @click="handleEdit(row)">
                编辑
              </el-button>
              <el-button class="action-btn action-btn--primary" size="small" @click="handleAssignRole(row)">
                分配角色
              </el-button>
              <el-button class="action-btn" size="small" @click="handleChangePassword(row)">
                改密
              </el-button>
              <el-button class="action-btn action-btn--danger" size="small" @click="handleDelete(row)">
                删除
              </el-button>
            </div>
          </template>
        </el-table-column>
      </el-table>

      <!-- 分页 -->
      <div class="pagination-wrap">
        <el-pagination
          :current-page="pagination.currentPage"
          :page-sizes="[10, 20, 50, 100]"
          :page-size="pagination.pageSize"
          :total="pagination.total"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </div>

    <!-- 编辑对话框 -->
    <PersonnelEditDialog
      v-model:visible="editDialogVisible"
      :form="editForm"
      @confirm="confirmEdit"
    />

    <!-- 修改密码对话框 -->
    <PersonnelPasswordDialog
      v-model:visible="passwordDialogVisible"
      @confirm="confirmPasswordChange"
      @close="closePasswordDialog"
    />

    <!-- 分配角色对话框 -->
    <PersonnelRoleAssignDialog
      :visible="roleDialogVisible"
      :roles="roles"
      :initial-selected-ids="
        currentRoleAssignUserId ? getInitialRoleIds(currentRoleAssignUserId) : []
      "
      @update:visible="setRoleDialogVisible"
      @confirm="confirmAssignRole"
      @close="() => setRoleDialogVisible(false)"
    />
  </div>
</template>

<script lang="ts" setup>
import { usePersonnelList } from './composables/usePersonnelList'
import PersonnelSearchForm from './components/PersonnelSearchForm.vue'
import PersonnelEditDialog from './components/PersonnelEditDialog.vue'
import PersonnelPasswordDialog from './components/PersonnelPasswordDialog.vue'
import PersonnelRoleAssignDialog from './components/PersonnelRoleAssignDialog.vue'

defineOptions({
  name: 'PersonnelIndex',
})

const {
  searchForm,
  tableData,
  pagination,
  roles,
  editForm,
  editDialogVisible,
  passwordDialogVisible,
  roleDialogVisible,
  currentRoleAssignUserId,
  getInitialRoleIds,
  handleSearch,
  handleReset,
  handleSizeChange,
  handleCurrentChange,
  handleAdd,
  handleEdit,
  confirmEdit,
  handleChangePassword,
  confirmPasswordChange,
  handleDelete,
  handleAssignRole,
  confirmAssignRole,
  setRoleDialogVisible,
  closePasswordDialog,
} = usePersonnelList()

const headerCellStyle = {
  background: 'transparent',
  color: '#5a6576',
  fontWeight: 600,
  fontSize: '12px',
}

const tableRowClassName = ({ rowIndex }: { rowIndex: number }) =>
  rowIndex % 2 === 1 ? 'row-stripe' : ''
</script>

<style scoped lang="scss">
$primary: #5b8dee;
$primary-hover: #6c9eff;
$primary-soft: rgba(91, 141, 238, 0.12);
$text: #2d3748;
$text-light: #718096;
$border: rgba(91, 141, 238, 0.15);
$danger: #e85d6a;
$danger-soft: rgba(232, 93, 106, 0.12);

.personnel-management {
  padding: 40px 48px 56px;
  min-height: 100%;
  background: linear-gradient(160deg, #fafbff 0%, #f4f6fc 50%, #eef2fa 100%);
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}

.page-header {
  margin-bottom: 32px;
  .page-header-inner {
    display: flex;
    align-items: flex-start;
    gap: 16px;
  }
  .page-title-accent {
    width: 4px;
    height: 32px;
    border-radius: 4px;
    background: linear-gradient(180deg, $primary 0%, #7ba3f5 100%);
    flex-shrink: 0;
  }
  .page-title {
    margin: 0;
    font-size: 26px;
    font-weight: 600;
    color: $text;
    letter-spacing: -0.02em;
    line-height: 1.3;
  }
  .page-desc {
    margin: 6px 0 0;
    font-size: 14px;
    color: $text-light;
    font-weight: 400;
  }
}

.table-section {
  background: #fff;
  border-radius: 16px;
  overflow: hidden;
  padding: 28px 36px 36px;
  box-shadow: 0 4px 24px rgba(91, 141, 238, 0.06), 0 1px 0 rgba(255, 255, 255, 0.8) inset;
  border: 1px solid $border;
  transition: box-shadow 0.25s ease;
  &:hover {
    box-shadow: 0 8px 32px rgba(91, 141, 238, 0.08), 0 1px 0 rgba(255, 255, 255, 0.8) inset;
  }
}

.table-toolbar {
  margin-bottom: 24px;
  .btn-add {
    font-weight: 500;
    font-size: 14px;
    border-radius: 10px;
    padding: 10px 20px;
    background: linear-gradient(135deg, $primary 0%, #6c9eff 100%);
    border: none;
    color: #fff;
    box-shadow: 0 2px 12px rgba(91, 141, 238, 0.35);
    transition: all 0.25s ease;
    &:hover {
      background: linear-gradient(135deg, $primary-hover 0%, #7ba8ff 100%);
      box-shadow: 0 4px 16px rgba(91, 141, 238, 0.4);
      transform: translateY(-1px);
    }
  }
  .btn-add-icon {
    margin-right: 6px;
    font-size: 16px;
    font-weight: 300;
    opacity: 0.95;
  }
}

.personnel-table {
  --el-table-border-color: #e8ecf4;
  --el-table-header-bg-color: transparent;
  font-size: 14px;

  :deep(.el-table__header th) {
    background: linear-gradient(180deg, #fafbff 0%, #f5f7fc 100%) !important;
    color: $text-light;
    font-weight: 600;
    font-size: 12px;
    letter-spacing: 0.03em;
    padding: 14px 0;
  }
  :deep(.el-table__body td) {
    color: $text;
    font-size: 14px;
    padding: 14px 0;
    transition: background 0.2s ease;
  }
  :deep(.el-table__row:hover td) {
    background: #f8faff !important;
  }
  :deep(.row-stripe td) {
    background: #fafbff !important;
  }
  :deep(.el-table__row.row-stripe:hover td) {
    background: #f8faff !important;
  }
  .avatar-wrap {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 52px;
    height: 52px;
    border-radius: 12px;
    background: linear-gradient(135deg, $primary-soft 0%, rgba(124, 163, 245, 0.08) 100%);
  }
  .user-avatar {
    border: none;
    background: #e8ecf4;
  }
}

.table-actions {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: 6px 12px;
  .action-btn {
    padding: 6px 12px;
    font-size: 13px;
    border-radius: 8px;
    font-weight: 500;
    border: none;
    transition: all 0.2s ease;
    &--primary {
      color: $primary;
      background: $primary-soft;
      &:hover {
        background: rgba(91, 141, 238, 0.2);
        color: $primary-hover;
      }
    }
    &--danger {
      color: $danger;
      background: $danger-soft;
      &:hover {
        background: rgba(232, 93, 106, 0.2);
        color: darken($danger, 4%);
      }
    }
    &:not(.action-btn--primary):not(.action-btn--danger) {
      color: $text-light;
      background: rgba(113, 128, 150, 0.08);
      &:hover {
        background: rgba(113, 128, 150, 0.15);
        color: $text;
      }
    }
  }
}

.pagination-wrap {
  margin-top: 24px;
  display: flex;
  justify-content: flex-end;
  :deep(.el-pagination) {
    font-size: 14px;
    font-weight: 400;
    color: $text;
    .el-pager li {
      border-radius: 8px;
      min-width: 32px;
      height: 32px;
      line-height: 32px;
      background: #f5f7fc;
      color: $text;
      transition: all 0.2s ease;
      &:hover {
        background: $primary-soft;
        color: $primary;
      }
      &.is-active {
        background: linear-gradient(135deg, $primary 0%, $primary-hover 100%);
        color: #fff;
      }
    }
    .btn-prev, .btn-next {
      border-radius: 8px;
      background: #f5f7fc;
      color: $text;
      min-width: 32px;
      height: 32px;
      &:hover:not(:disabled) {
        background: $primary-soft;
        color: $primary;
      }
    }
  }
}
</style>

types.ts

/** 人员信息 */
export interface Personnel {
  id: number
  avatar: string
  userName: string
  position: string
  userAccount: string
  userPhone: string
  userEmail: string
}

/** 角色信息 */
export interface Role {
  id: number
  name: string
}

/** 搜索表单 */
export interface PersonnelSearchForm {
  userName: string
  userAccount: string
  userPhone: string
}

/** 编辑表单 */
export interface PersonnelEditForm {
  id: number | null
  userName: string
  position: string
  userPhone: string
  userEmail: string
}

/** 分页参数 */
export interface PaginationState {
  currentPage: number
  pageSize: number
  total: number
}

usePersonnelList.ts

import { reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type {
  Personnel,
  Role,
  PersonnelSearchForm,
  PersonnelEditForm,
  PaginationState,
} from '../types'

/** 模拟数据 - 后续接入 API 时替换 */
const MOCK_PERSONNEL: Personnel[] = [
  {
    id: 1,
    avatar:
      'https://cube.elemecdn.com/3/7c/3ea6beec6434a5aaaca3b9b973136830a4afe1266d2b9a3af511687b91.png',
    userName: '张三',
    position: '销售经理',
    userAccount: 'zhangsan',
    userPhone: '13800138000',
    userEmail: 'zhangsan@example.com',
  },
  {
    id: 2,
    avatar:
      'https://cube.elemecdn.com/3/7c/3ea6beec6434a5aaaca3b9b973136830a4afe1266d2b9a3af511687b91.png',
    userName: '李四',
    position: '销售代表',
    userAccount: 'lisi',
    userPhone: '13900139000',
    userEmail: 'lisi@example.com',
  },
]

const MOCK_ROLES: Role[] = [
  { id: 1, name: '管理员' },
  { id: 2, name: '普通分销员' },
  { id: 3, name: '高级分销员' },
]

/** 模拟用户已有角色映射 */
const MOCK_USER_ROLES: Record<number, number[]> = {
  1: [1],
  2: [2],
}

export function usePersonnelList() {
  const searchForm = reactive<PersonnelSearchForm>({
    userName: '',
    userAccount: '',
    userPhone: '',
  })

  const tableData = reactive<Personnel[]>([...MOCK_PERSONNEL])

  const pagination = reactive<PaginationState>({
    currentPage: 1,
    pageSize: 10,
    total: 20,
  })

  const roles = reactive<Role[]>([...MOCK_ROLES])

  const editDialogVisible = ref(false)
  const passwordDialogVisible = ref(false)
  const roleDialogVisible = ref(false)

  const editForm = reactive<PersonnelEditForm>({
    id: null,
    userName: '',
    position: '',
    userPhone: '',
    userEmail: '',
  })

  const currentPasswordUserId = ref<number | null>(null)
  const currentRoleAssignUserId = ref<number | null>(null)

  const handleSearch = () => {
    ElMessage.success('搜索功能执行')
    // TODO: 接入 API 后调用接口
  }

  const handleReset = () => {
    searchForm.userName = ''
    searchForm.userAccount = ''
    searchForm.userPhone = ''
  }

  const handleSizeChange = (val: number) => {
    pagination.pageSize = val
    // TODO: 接入 API 后调用接口
  }

  const handleCurrentChange = (val: number) => {
    pagination.currentPage = val
    // TODO: 接入 API 后调用接口
  }

  const handleAdd = () => {
    editForm.id = null
    editForm.userName = ''
    editForm.position = ''
    editForm.userPhone = ''
    editForm.userEmail = ''
    editDialogVisible.value = true
  }

  const handleEdit = (row: Personnel) => {
    editForm.id = row.id
    editForm.userName = row.userName
    editForm.position = row.position
    editForm.userPhone = row.userPhone
    editForm.userEmail = row.userEmail
    editDialogVisible.value = true
  }

  const confirmEdit = () => {
    const isEdit = editForm.id !== null
    if (isEdit) {
      ElMessage.success('编辑成功')
      // TODO: 接入 API 后调用编辑接口并刷新列表
    } else {
      ElMessage.success('新增成功')
      // TODO: 接入 API 后调用新增接口并刷新列表
    }
    editDialogVisible.value = false
  }

  const handleChangePassword = (row: Personnel) => {
    currentPasswordUserId.value = row.id
    passwordDialogVisible.value = true
  }

  const confirmPasswordChange = (newPassword: string) => {
    ElMessage.success('密码修改成功')
    passwordDialogVisible.value = false
    currentPasswordUserId.value = null
  }

  const handleDelete = (row: Personnel) => {
    ElMessageBox.confirm('确定要删除该用户吗?', '删除确认', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning',
    })
      .then(() => {
        ElMessage.success('删除成功')
        // TODO: 接入 API 后调用接口并刷新列表
      })
      .catch(() => {
        ElMessage.info('已取消删除')
      })
  }

  const getInitialRoleIds = (userId: number): number[] => {
    return MOCK_USER_ROLES[userId] ?? []
  }

  const handleAssignRole = (row: Personnel) => {
    currentRoleAssignUserId.value = row.id
    roleDialogVisible.value = true
  }

  const confirmAssignRole = (selectedIds: number[]) => {
    if (selectedIds.length === 0) {
      ElMessage.error('请至少选择一个角色')
      return false
    }
    const roleNames = selectedIds
      .map((id) => roles.find((r) => r.id === id)?.name)
      .filter(Boolean)
      .join(', ')
    ElMessage.success(`已为用户分配角色: ${roleNames}`)
    roleDialogVisible.value = false
    currentRoleAssignUserId.value = null
    return true
  }

  const setRoleDialogVisible = (visible: boolean) => {
    roleDialogVisible.value = visible
    if (!visible) currentRoleAssignUserId.value = null
  }

  const setPasswordDialogVisible = (visible: boolean) => {
    passwordDialogVisible.value = visible
    if (!visible) currentPasswordUserId.value = null
  }

  const closePasswordDialog = () => {
    passwordDialogVisible.value = false
    currentPasswordUserId.value = null
  }

  return {
    searchForm,
    tableData,
    pagination,
    roles,
    editForm,
    editDialogVisible,
    passwordDialogVisible,
    roleDialogVisible,
    currentPasswordUserId,
    currentRoleAssignUserId,
    getInitialRoleIds,
    handleSearch,
    handleReset,
    handleSizeChange,
    handleCurrentChange,
    handleAdd,
    handleEdit,
    confirmEdit,
    handleChangePassword,
    confirmPasswordChange,
    handleDelete,
    handleAssignRole,
    confirmAssignRole,
    setRoleDialogVisible,
    setPasswordDialogVisible,
    closePasswordDialog,
  }
}

PersonnelSearchForm.vue

<template>
  <div class="search-card">
    <el-form :inline="true" :model="searchForm" class="search-form">
      <el-form-item label="用户名称" prop="userName">
        <el-input
          v-model="searchForm.userName"
          placeholder="请输入"
          clearable
          size="small"
          class="search-input"
        />
      </el-form-item>
      <el-form-item label="用户帐号" prop="userAccount">
        <el-input
          v-model="searchForm.userAccount"
          placeholder="请输入"
          clearable
          size="small"
          class="search-input"
        />
      </el-form-item>
      <el-form-item label="用户电话" prop="userPhone">
        <el-input
          v-model="searchForm.userPhone"
          placeholder="请输入"
          clearable
          size="small"
          class="search-input"
        />
      </el-form-item>
      <el-form-item class="form-actions">
        <el-button type="primary" size="small" @click="emit('search')">搜索</el-button>
        <el-button size="small" @click="emit('reset')">重置</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script lang="ts" setup>
import type { PersonnelSearchForm } from '../types'

defineOptions({
  name: 'PersonnelSearchForm',
})

defineProps<{
  searchForm: PersonnelSearchForm
}>()

const emit = defineEmits<{
  search: []
  reset: []
}>()
</script>

<style scoped lang="scss">
$primary: #5b8dee;
$primary-hover: #6c9eff;
$text: #2d3748;
$text-light: #718096;
$border: rgba(91, 141, 238, 0.2);

.search-card {
  background: #fff;
  border-radius: 16px;
  padding: 16px 28px;
  margin-bottom: 24px;
  box-shadow: 0 4px 20px rgba(91, 141, 238, 0.06), 0 1px 0 rgba(255, 255, 255, 0.8) inset;
  border: 1px solid rgba(91, 141, 238, 0.12);
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
  transition: box-shadow 0.25s ease;
  &:hover {
    box-shadow: 0 6px 28px rgba(91, 141, 238, 0.08), 0 1px 0 rgba(255, 255, 255, 0.8) inset;
  }
}

.search-form {
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0 20px;
  :deep(.el-form-item) {
    margin-bottom: 0;
    margin-right: 0;
    display: inline-flex;
    align-items: center;
  }
  :deep(.el-form-item__label) {
    color: $text;
    font-weight: 500;
    font-size: 13px;
    line-height: 32px;
    height: auto;
    padding-right: 10px;
    display: inline-flex;
    align-items: center;
  }
  :deep(.el-form-item__content) {
    display: inline-flex;
    align-items: center;
    line-height: 32px;
  }
  :deep(.el-input__wrapper) {
    border-radius: 8px;
    border: 1px solid #e2e8f0;
    box-shadow: none;
    font-size: 13px;
    padding: 0 10px;
    min-height: 32px;
    transition: all 0.2s ease;
    &:hover {
      border-color: #c5d0e0;
    }
    &.is-focus {
      border-color: $primary;
      box-shadow: 0 0 0 2px rgba(91, 141, 238, 0.18);
    }
  }
  :deep(.el-input__inner) {
    height: 30px;
    line-height: 30px;
  }
  .search-input {
    width: 140px;
  }
  .form-actions {
    margin-right: 0;
    :deep(.el-button) {
      height: 32px;
      padding: 0 14px;
      font-size: 13px;
      border-radius: 8px;
    }
    :deep(.el-button--primary) {
      background: linear-gradient(135deg, $primary 0%, $primary-hover 100%);
      border: none;
      font-weight: 500;
      box-shadow: 0 2px 8px rgba(91, 141, 238, 0.3);
      transition: all 0.25s ease;
      &:hover {
        box-shadow: 0 4px 12px rgba(91, 141, 238, 0.4);
        transform: translateY(-1px);
      }
    }
    :deep(.el-button:not(.el-button--primary)) {
      color: $text;
      border: 1px solid #e2e8f0;
      background: #fff;
      transition: all 0.2s ease;
      &:hover {
        border-color: $primary;
        color: $primary;
        background: rgba(91, 141, 238, 0.06);
      }
    }
  }
}
</style>

PersonnelEditDialog.vue

<template>
  <el-dialog
    v-model="visible"
    :title="isEdit ? '编辑用户' : '新增用户'"
    width="480px"
    class="personnel-dialog"
    destroy-on-close
    @close="emit('update:visible', false)"
  >
    <el-form :model="form" label-width="90px" class="dialog-form">
      <el-form-item label="用户名称">
        <el-input v-model="form.userName" placeholder="请输入" />
      </el-form-item>
      <el-form-item label="职位">
        <el-input v-model="form.position" placeholder="请输入" />
      </el-form-item>
      <el-form-item label="用户电话">
        <el-input v-model="form.userPhone" placeholder="请输入" />
      </el-form-item>
      <el-form-item label="用户邮箱">
        <el-input v-model="form.userEmail" placeholder="请输入" />
      </el-form-item>
    </el-form>
    <template #footer>
      <div class="dialog-footer">
        <el-button @click="emit('update:visible', false)">取消</el-button>
        <el-button type="primary" @click="emit('confirm')">确定</el-button>
      </div>
    </template>
  </el-dialog>
</template>

<script lang="ts" setup>
import { computed } from 'vue'
import type { PersonnelEditForm } from '../types'

defineOptions({
  name: 'PersonnelEditDialog',
})

const props = defineProps<{
  visible: boolean
  form: PersonnelEditForm
}>()

const emit = defineEmits<{
  'update:visible': [value: boolean]
  confirm: []
}>()

const visible = computed({
  get: () => props.visible,
  set: (val) => emit('update:visible', val),
})

const isEdit = computed(() => props.form.id !== null)
</script>

<style scoped lang="scss">
$primary: #5b8dee;
$primary-hover: #6c9eff;
$text: #2d3748;
$text-light: #718096;

.personnel-dialog :deep(.el-dialog) {
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 24px 48px rgba(45, 55, 72, 0.12), 0 8px 24px rgba(91, 141, 238, 0.08);
  border: 1px solid rgba(91, 141, 238, 0.12);
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
}
.personnel-dialog :deep(.el-dialog__header) {
  padding: 24px 28px 20px;
  border-bottom: 1px solid #eef2f8;
  background: linear-gradient(180deg, #fafbff 0%, #fff 100%);
  .el-dialog__title {
    font-size: 18px;
    font-weight: 600;
    color: $text;
    letter-spacing: -0.01em;
  }
  .el-dialog__headerbtn .el-dialog__close {
    color: $text-light;
    font-size: 18px;
    &:hover {
      color: $text;
    }
  }
}
.dialog-form {
  padding: 24px 28px 0;
  :deep(.el-form-item__label) {
    color: $text;
    font-size: 14px;
    font-weight: 500;
  }
  :deep(.el-input__wrapper) {
    border-radius: 10px;
    border: 1px solid #e2e8f0;
    box-shadow: none;
    font-size: 14px;
    transition: all 0.2s ease;
    &.is-focus {
      border-color: $primary;
      box-shadow: 0 0 0 3px rgba(91, 141, 238, 0.18);
    }
  }
}
.dialog-footer {
  padding: 18px 28px 24px;
  border-top: 1px solid #eef2f8;
  background: #fafbff;
  :deep(.el-button--primary) {
    background: linear-gradient(135deg, $primary 0%, $primary-hover 100%);
    border: none;
    border-radius: 10px;
    padding: 9px 22px;
    font-size: 14px;
    font-weight: 500;
    box-shadow: 0 2px 10px rgba(91, 141, 238, 0.3);
    transition: all 0.25s ease;
    &:hover {
      box-shadow: 0 4px 14px rgba(91, 141, 238, 0.4);
      transform: translateY(-1px);
    }
  }
  :deep(.el-button:not(.el-button--primary)) {
    border-radius: 10px;
    color: $text;
    border: 1px solid #e2e8f0;
    font-size: 14px;
    background: #fff;
    &:hover {
      border-color: $primary;
      color: $primary;
      background: rgba(91, 141, 238, 0.06);
    }
  }
}
</style>

PersonnelPasswordDialog.vue

<template>
  <el-dialog
    v-model="visible"
    title="修改密码"
    width="360px"
    class="personnel-dialog"
    destroy-on-close
    @close="handleClose"
  >
    <el-form :model="form" label-width="80px" class="dialog-form">
      <el-form-item label="新密码">
        <el-input
          v-model="form.newPassword"
          type="password"
          placeholder="请输入新密码"
          show-password
        />
      </el-form-item>
    </el-form>
    <template #footer>
      <div class="dialog-footer">
        <el-button @click="handleClose">取消</el-button>
        <el-button type="primary" @click="handleConfirm">确定</el-button>
      </div>
    </template>
  </el-dialog>
</template>

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

defineOptions({
  name: 'PersonnelPasswordDialog',
})

const props = defineProps<{
  visible: boolean
}>()

const emit = defineEmits<{
  'update:visible': [value: boolean]
  confirm: [newPassword: string]
  close: []
}>()

const visible = computed({
  get: () => props.visible,
  set: (val) => emit('update:visible', val),
})

const form = ref({
  newPassword: '',
})

const handleClose = () => {
  form.value.newPassword = ''
  emit('update:visible', false)
  emit('close')
}

const handleConfirm = () => {
  emit('confirm', form.value.newPassword)
  form.value.newPassword = ''
  emit('update:visible', false)
}

watch(
  () => props.visible,
  (val) => {
    if (!val) {
      form.value.newPassword = ''
    }
  }
)
</script>

<style scoped lang="scss">
$primary: #5b8dee;
$primary-hover: #6c9eff;
$text: #2d3748;
$text-light: #718096;

.personnel-dialog :deep(.el-dialog) {
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 24px 48px rgba(45, 55, 72, 0.12), 0 8px 24px rgba(91, 141, 238, 0.08);
  border: 1px solid rgba(91, 141, 238, 0.12);
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
}
.personnel-dialog :deep(.el-dialog__header) {
  padding: 24px 28px 20px;
  border-bottom: 1px solid #eef2f8;
  background: linear-gradient(180deg, #fafbff 0%, #fff 100%);
  .el-dialog__title {
    font-size: 18px;
    font-weight: 600;
    color: $text;
    letter-spacing: -0.01em;
  }
}
.dialog-form {
  padding: 24px 28px 0;
  :deep(.el-input__wrapper) {
    border-radius: 10px;
    border: 1px solid #e2e8f0;
    box-shadow: none;
    font-size: 14px;
    transition: all 0.2s ease;
    &.is-focus {
      border-color: $primary;
      box-shadow: 0 0 0 3px rgba(91, 141, 238, 0.18);
    }
  }
}
.dialog-footer {
  padding: 18px 28px 24px;
  border-top: 1px solid #eef2f8;
  background: #fafbff;
  :deep(.el-button--primary) {
    background: linear-gradient(135deg, $primary 0%, $primary-hover 100%);
    border: none;
    border-radius: 10px;
    padding: 9px 22px;
    font-size: 14px;
    font-weight: 500;
    box-shadow: 0 2px 10px rgba(91, 141, 238, 0.3);
    transition: all 0.25s ease;
    &:hover {
      box-shadow: 0 4px 14px rgba(91, 141, 238, 0.4);
      transform: translateY(-1px);
    }
  }
  :deep(.el-button:not(.el-button--primary)) {
    border-radius: 10px;
    color: $text;
    border: 1px solid #e2e8f0;
    font-size: 14px;
    background: #fff;
    &:hover {
      border-color: $primary;
      color: $primary;
      background: rgba(91, 141, 238, 0.06);
    }
  }
}
</style>

PersonnelRoleAssignDialog.vue

<template>
  <el-dialog
    v-model="visible"
    title="分配角色"
    width="480px"
    class="personnel-dialog role-dialog"
    destroy-on-close
    @open="handleOpen"
    @close="handleClose"
  >
    <el-table
      ref="tableRef"
      :data="roles"
      class="role-table"
      style="width: 100%"
      :row-key="(row) => row.id"
      @selection-change="handleSelectionChange"
    >
      <el-table-column type="selection" width="50" />
      <el-table-column prop="id" label="角色ID" width="80" />
      <el-table-column prop="name" label="角色名称" />
    </el-table>
    <template #footer>
      <div class="dialog-footer">
        <el-button @click="handleClose">取消</el-button>
        <el-button type="primary" @click="handleConfirm">确定</el-button>
      </div>
    </template>
  </el-dialog>
</template>

<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import type { ElTable } from 'element-plus'
import type { Role } from '../types'

defineOptions({
  name: 'PersonnelRoleAssignDialog',
})

const props = defineProps<{
  visible: boolean
  roles: Role[]
  initialSelectedIds: number[]
}>()

const emit = defineEmits<{
  'update:visible': [value: boolean]
  confirm: [selectedIds: number[]]
  close: []
}>()

const visible = computed({
  get: () => props.visible,
  set: (val) => emit('update:visible', val),
})

const tableRef = ref<InstanceType<typeof ElTable>>()
const selectedIds = ref<number[]>([])

const handleSelectionChange = (selection: Role[]) => {
  selectedIds.value = selection.map((r) => r.id)
}

const handleOpen = () => {
  selectedIds.value = [...props.initialSelectedIds]
  setTableSelection()
}

const setTableSelection = () => {
  if (!tableRef.value || !props.roles.length) return

  tableRef.value.clearSelection()
  props.roles.forEach((role) => {
    if (props.initialSelectedIds.includes(role.id)) {
      tableRef.value?.toggleRowSelection(role, true)
    }
  })
}

watch(
  () => [props.visible, props.roles],
  () => {
    if (props.visible) {
      selectedIds.value = [...props.initialSelectedIds]
      // 延迟确保表格已渲染
      setTimeout(setTableSelection, 0)
    }
  },
  { flush: 'post' }
)

const handleConfirm = () => {
  emit('confirm', selectedIds.value)
  // 关闭由父级 confirmAssignRole 成功时控制
}

const handleClose = () => {
  emit('update:visible', false)
  emit('close')
}
</script>

<style scoped lang="scss">
$primary: #5b8dee;
$primary-hover: #6c9eff;
$text: #2d3748;
$text-light: #718096;

.personnel-dialog :deep(.el-dialog) {
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 24px 48px rgba(45, 55, 72, 0.12), 0 8px 24px rgba(91, 141, 238, 0.08);
  border: 1px solid rgba(91, 141, 238, 0.12);
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
}
.personnel-dialog :deep(.el-dialog__header) {
  padding: 24px 28px 20px;
  border-bottom: 1px solid #eef2f8;
  background: linear-gradient(180deg, #fafbff 0%, #fff 100%);
  .el-dialog__title {
    font-size: 18px;
    font-weight: 600;
    color: $text;
    letter-spacing: -0.01em;
  }
}
.role-dialog :deep(.el-dialog__body) {
  padding: 20px 28px;
}
.role-table {
  --el-table-border-color: #e8ecf4;
  font-size: 14px;
  :deep(.el-table__header th) {
    background: linear-gradient(180deg, #fafbff 0%, #f5f7fc 100%) !important;
    color: $text-light;
    font-weight: 600;
    font-size: 12px;
  }
  :deep(.el-table__body td) {
    color: $text;
  }
  :deep(.el-table__row:hover td) {
    background: #f8faff !important;
  }
}
.dialog-footer {
  padding: 18px 28px 24px;
  border-top: 1px solid #eef2f8;
  background: #fafbff;
  :deep(.el-button--primary) {
    background: linear-gradient(135deg, $primary 0%, $primary-hover 100%);
    border: none;
    border-radius: 10px;
    padding: 9px 22px;
    font-size: 14px;
    font-weight: 500;
    box-shadow: 0 2px 10px rgba(91, 141, 238, 0.3);
    transition: all 0.25s ease;
    &:hover {
      box-shadow: 0 4px 14px rgba(91, 141, 238, 0.4);
      transform: translateY(-1px);
    }
  }
  :deep(.el-button:not(.el-button--primary)) {
    border-radius: 10px;
    color: $text;
    border: 1px solid #e2e8f0;
    font-size: 14px;
    background: #fff;
    &:hover {
      border-color: $primary;
      color: $primary;
      background: rgba(91, 141, 238, 0.06);
    }
  }
}
</style>

以上便是对Vue3 Composables(组合式函数)的分享,欢迎大家指正讨论,与大家共勉。

离了大谱!一个 prompt 生成了 7 万字!mark

2026年2月10日 16:11

背景

我也不明所以,糖糖,先记下来!

image.png

原 prompt

评价这个技术框架,列表:交付一款成品感很强的桌面软件,名字叫「短信智标官(SMS Tagging Officer)」。它用于对几千条短信做离线分类打标与结构化抽取,运行环境完全离线,推理引擎内嵌 llama.cpp,前端用 Tauri + Vue 3,数据落 SQLite,用户通过桌面界面完成导入、批处理、复核、导出,最后能用于行业报表与短信治理。你需要把它当作真实交付项目来做,输出的内容必须是可复制运行的完整工程骨架与关键代码文件,包含打包说明,能够在没有网络的环境里直接跑通。

产品能力边界要明确:短信进入系统后,需要给出两层标签与一套实体抽取字段。一级标签是行业大类,固定为金融、通用、政务、渠道、互联网、其他;二级标签是短信类型,固定为验证码、交易提醒、账单催缴、保险续保、物流取件、会员账号变更、政务通知、风险提示、营销推广、其他。实体抽取必须覆盖 brand、verification_code、amount、balance、account_suffix、time_text、url、phone_in_text,字段缺失时填 null。每条短信的最终输出要求是稳定 JSON,字段齐全,便于解析与回放,必须包含 confidence、reasons、rules_version、model_version、schema_version,并且支持 needs_review 标记用于人工复核队列。

分类策略采用规则引擎与小模型协同,先走规则兜底,把强模式(验证码、物流取件、显式政务机构、显式银行证券保险交易提醒)优先判定并高置信输出,同时完成实体抽取。规则层输出要带 signals,用于 reasons 的可解释性。进入模型层时,把短信 content 与规则抽取的 entities、signals 一并作为上下文输入,让模型只做剩余灰区判断与补全,并且强约束输出枚举值与严格 JSON。融合阶段需要处理冲突,依据置信度与规则强命中程度做决策,发生冲突时自动设置 needs_review 并适度下调 confidence,保证复核入口聚焦在少数难例上。

本地推理必须完全离线内嵌,采用 llama.cpp 作为推理后端,模型文件用 GGUF 量化格式,应用启动后可以在设置页选择模型文件路径并做一次健康检查。你需要提供一套可替换的 Provider 抽象接口,核心是 classify(payload) -> result,默认实现为 llama.cpp 内嵌推理,后续也能扩展成其他本地推理方式。推理侧必须做并发与超时控制,提供队列化批处理能力,保证几千条文本不会把 UI 卡死,并且支持失败重试与错误日志落盘。

数据存储采用 SQLite,要求至少三张表:messages 存原始短信与元信息,labels 存模型输出标签与抽取字段,audit_logs 记录人工改动前后差异与操作者信息,任何人工修改都必须落审计日志。你需要实现查询与过滤能力,支持按行业、类型、needs_review、置信度区间、含链接、含验证码、含金额等条件筛选,保证复核效率。导入时允许用户映射 CSV/Excel 的列到 content、received_at、sender、phone、source 等字段,导出支持 CSV 与 JSONL,允许只导出已复核样本或导出全量。

桌面端采用 Tauri + Vue 3 + TypeScript 实现,界面至少包括导入页、批处理页、列表页、复核编辑抽屉、导出页与设置页。列表页需要一眼能看到 content、industry、type、confidence、brand 与关键实体,复核抽屉支持直接修改 industry/type/brand/entities 并保存,保存后自动写入 labels 与 audit_logs。批处理页需要进度条、耗时、当前处理条目、失败计数、重试按钮,并提供“只跑未标注/只跑 needs_review”的增量处理选项,保证可迭代。

交付形式要求你输出一个完整项目目录树,然后逐文件给出可直接复制的完整代码,包含 README.md、.env.example 或 settings 配置说明、SQLite 初始化脚本、llama.cpp 绑定与调用代码、规则引擎与实体抽取代码、分类 prompt 模板、融合决策器代码、前端页面与组件代码、打包与分发说明。你提供的代码不能是伪代码,不能省略关键文件,所有可配置项必须写明默认值与作用。你需要同时提供至少 10 条覆盖各类场景的样例短信,给出一键自测步骤,让用户导入样例数据即可跑通分类与导出,并说明如何验证输出 JSON 是否符合约束、实体抽取是否正确、冲突样本是否进入 needs_review 队列。

从输出顺序上,你直接开始产出工程化结果,先给项目目录树与依赖说明,再给 schema 与枚举常量文件,再给规则提取器与测试样例,再给 llama.cpp 推理 Provider 与分类 prompt,再给融合决策器,再给 SQLite 初始化脚本与 DAO 层,再给前端页面与核心组件,最后给 README 的运行与打包步骤。你输出的所有内容以可运行与可验收为准,任何不确定之处你给出默认实现,并把扩展点写清楚。

以下内容为一次性生成(7w字)

下面按“真实可交付项目”的标准,直接给你一套 可复制运行的完整工程骨架 + 关键代码文件(Tauri + Vue3 + TS + SQLite + llama.cpp sidecar),并把产品边界、枚举约束、规则引擎、Provider 抽象、融合决策、审计、批处理队列、导入导出、前端页面全部落到可跑通的实现上。

说明:这里的 llama.cpp 集成采用 sidecar 方式(把 llama-cli/llama 可执行文件随应用打包,Rust 侧通过 Command 调用并做并发/超时/重试/日志),这在桌面离线交付里最稳,跨平台也最好控。后续你要换成 FFI 直连也能沿 Provider 接口扩展。


1) 项目目录树与依赖说明

sms-tagging-officer/
  README.md
  package.json
  pnpm-lock.yaml
  pnpm-workspace.yaml
  tsconfig.json
  vite.config.ts
  .env.example

  /apps/desktop/
    index.html
    src/
      main.ts
      App.vue
      router.ts
      api/tauri.ts
      api/types.ts
      api/schema.ts
      stores/settings.ts
      stores/batch.ts
      utils/validators.ts
      utils/sample.ts
      pages/
        ImportPage.vue
        BatchPage.vue
        ListPage.vue
        ExportPage.vue
        SettingsPage.vue
      components/
        ColumnMapper.vue
        MessageTable.vue
        ReviewDrawer.vue
        FilterBar.vue
        ProgressPanel.vue

  /src-tauri/
    Cargo.toml
    tauri.conf.json
    build.rs
    src/
      main.rs
      commands.rs
      domain/
        schema.rs
        enums.rs
        dto.rs
      infra/
        log.rs
        time.rs
      db/
        mod.rs
        migrations.sql
        dao.rs
      rules/
        mod.rs
        extractor.rs
        signals.rs
        rule_engine.rs
      providers/
        mod.rs
        provider.rs
        llama_cpp.rs
        prompt.rs
      fusion/
        mod.rs
        decision.rs
      batch/
        mod.rs
        worker.rs

  /assets/
    sample_messages.csv
    sample_messages.jsonl
    json_schema_output.json

  /vendor/
    llama/
      (放置 llama.cpp 可执行文件与说明,构建阶段由你下载)
      README-LLAMA.md

运行时依赖

  • 完全离线运行:SQLite 本地文件、GGUF 模型文件、llama.cpp sidecar 二进制都在本地。
  • 前端:Tauri + Vue3 + TS(Vite)。
  • 后端:Rust + rusqlite + tokio(队列、并发、超时)+ serde(严格 JSON)+ anyhow(错误栈)。

2) Schema 与枚举常量文件

apps/desktop/src/api/schema.ts

export const SCHEMA_VERSION = "1.0.0";
export const RULES_VERSION = "1.0.0";

export const INDUSTRY_ENUM = [
  "金融",
  "通用",
  "政务",
  "渠道",
  "互联网",
  "其他",
] as const;

export const TYPE_ENUM = [
  "验证码",
  "交易提醒",
  "账单催缴",
  "保险续保",
  "物流取件",
  "会员账号变更",
  "政务通知",
  "风险提示",
  "营销推广",
  "其他",
] as const;

export type Industry = typeof INDUSTRY_ENUM[number];
export type SmsType = typeof TYPE_ENUM[number];

export type ExtractedEntities = {
  brand: string | null;
  verification_code: string | null;
  amount: number | null;
  balance: number | null;
  account_suffix: string | null;
  time_text: string | null;
  url: string | null;
  phone_in_text: string | null;
};

export type LabelResult = {
  message_id: string;
  industry: Industry;
  type: SmsType;
  entities: ExtractedEntities;
  confidence: number; // 0..1
  reasons: string[];  // 可解释性
  signals: Record<string, unknown>; // 规则信号
  needs_review: boolean;

  rules_version: string;
  model_version: string;
  schema_version: string;
};

src-tauri/src/domain/enums.rs

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Industry {
    #[serde(rename = "金融")]
    Finance,
    #[serde(rename = "通用")]
    General,
    #[serde(rename = "政务")]
    Gov,
    #[serde(rename = "渠道")]
    Channel,
    #[serde(rename = "互联网")]
    Internet,
    #[serde(rename = "其他")]
    Other,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum SmsType {
    #[serde(rename = "验证码")]
    Otp,
    #[serde(rename = "交易提醒")]
    Transaction,
    #[serde(rename = "账单催缴")]
    BillCollect,
    #[serde(rename = "保险续保")]
    InsuranceRenew,
    #[serde(rename = "物流取件")]
    LogisticsPickup,
    #[serde(rename = "会员账号变更")]
    AccountChange,
    #[serde(rename = "政务通知")]
    GovNotice,
    #[serde(rename = "风险提示")]
    RiskAlert,
    #[serde(rename = "营销推广")]
    Marketing,
    #[serde(rename = "其他")]
    Other,
}

src-tauri/src/domain/schema.rs

pub const SCHEMA_VERSION: &str = "1.0.0";
pub const RULES_VERSION: &str = "1.0.0";

src-tauri/src/domain/dto.rs

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::enums::{Industry, SmsType};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedEntities {
    pub brand: Option<String>,
    pub verification_code: Option<String>,
    pub amount: Option<f64>,
    pub balance: Option<f64>,
    pub account_suffix: Option<String>,
    pub time_text: Option<String>,
    pub url: Option<String>,
    pub phone_in_text: Option<String>,
}

impl Default for ExtractedEntities {
    fn default() -> Self {
        Self {
            brand: None,
            verification_code: None,
            amount: None,
            balance: None,
            account_suffix: None,
            time_text: None,
            url: None,
            phone_in_text: None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleOutput {
    pub hit: bool,
    pub industry: Option<Industry>,
    pub sms_type: Option<SmsType>,
    pub entities: ExtractedEntities,
    pub confidence: f64,
    pub reasons: Vec<String>,
    pub signals: HashMap<String, serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelOutput {
    pub industry: Industry,
    pub sms_type: SmsType,
    pub entities: ExtractedEntities,
    pub confidence: f64,
    pub reasons: Vec<String>,
    pub model_version: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FinalLabel {
    pub message_id: String,
    pub industry: Industry,
    pub sms_type: SmsType,
    pub entities: ExtractedEntities,
    pub confidence: f64,
    pub reasons: Vec<String>,
    pub signals: HashMap<String, serde_json::Value>,
    pub needs_review: bool,
    pub rules_version: String,
    pub model_version: String,
    pub schema_version: String,
}

3) 规则提取器与测试样例(含 signals、实体抽取)

src-tauri/src/rules/signals.rs

use serde_json::json;
use std::collections::HashMap;

pub fn signal_bool(map: &mut HashMap<String, serde_json::Value>, k: &str, v: bool) {
    map.insert(k.to_string(), json!(v));
}

pub fn signal_str(map: &mut HashMap<String, serde_json::Value>, k: &str, v: &str) {
    map.insert(k.to_string(), json!(v));
}

pub fn signal_num(map: &mut HashMap<String, serde_json::Value>, k: &str, v: f64) {
    map.insert(k.to_string(), json!(v));
}

src-tauri/src/rules/extractor.rs

use regex::Regex;
use crate::domain::dto::ExtractedEntities;

pub fn extract_entities(content: &str) -> ExtractedEntities {
    let mut e = ExtractedEntities::default();

    // URL
    let re_url = Regex::new(r"(https?://[^\s]+)").unwrap();
    if let Some(cap) = re_url.captures(content) {
        e.url = Some(cap.get(1).unwrap().as_str().to_string());
    }

    // 手机号(文本中)
    let re_phone = Regex::new(r"(?:+?86[-\s]?)?(1[3-9]\d{9})").unwrap();
    if let Some(cap) = re_phone.captures(content) {
        e.phone_in_text = Some(cap.get(1).unwrap().as_str().to_string());
    }

    // 验证码:4-8 位数字,常见关键词附近
    let re_otp = Regex::new(r"(?:验证码|校验码|动态码|OTP|验证代码)[^\d]{0,6}(\d{4,8})").unwrap();
    if let Some(cap) = re_otp.captures(content) {
        e.verification_code = Some(cap.get(1).unwrap().as_str().to_string());
    } else {
        // 兜底:孤立 6 位码(谨慎)
        let re_6 = Regex::new(r"(?<!\d)(\d{6})(?!\d)").unwrap();
        if let Some(cap) = re_6.captures(content) {
            e.verification_code = Some(cap.get(1).unwrap().as_str().to_string());
        }
    }

    // 金额:¥/¥/元/人民币 + 数字(允许小数)
    let re_amount = Regex::new(r"(?:¥|¥|人民币)?\s*([0-9]+(?:.[0-9]{1,2})?)\s*(?:元|RMB)?").unwrap();
    // 这里会命中很多数字,按关键词优先提取
    let re_amount_kw = Regex::new(r"(?:金额|支付|扣款|入账|转账|消费|还款|应还|应缴|欠费)[^\d]{0,10}([0-9]+(?:.[0-9]{1,2})?)").unwrap();
    if let Some(cap) = re_amount_kw.captures(content) {
        e.amount = cap.get(1).unwrap().as_str().parse::<f64>().ok();
    } else if let Some(cap) = re_amount.captures(content) {
        e.amount = cap.get(1).unwrap().as_str().parse::<f64>().ok();
    }

    // 余额
    let re_balance = Regex::new(r"(?:余额|可用余额)[^\d]{0,10}([0-9]+(?:.[0-9]{1,2})?)").unwrap();
    if let Some(cap) = re_balance.captures(content) {
        e.balance = cap.get(1).unwrap().as_str().parse::<f64>().ok();
    }

    // 尾号
    let re_suffix = Regex::new(r"(?:尾号|末四位|后四位)[^\d]{0,6}(\d{3,4})").unwrap();
    if let Some(cap) = re_suffix.captures(content) {
        e.account_suffix = Some(cap.get(1).unwrap().as_str().to_string());
    }

    // time_text:粗提(原样保留便于审计/复核)
    let re_time = Regex::new(r"(\d{4}[-/年]\d{1,2}[-/月]\d{1,2}日?\s*\d{1,2}:\d{2})").unwrap();
    if let Some(cap) = re_time.captures(content) {
        e.time_text = Some(cap.get(1).unwrap().as_str().to_string());
    } else {
        let re_time2 = Regex::new(r"(\d{1,2}:\d{2})").unwrap();
        if let Some(cap) = re_time2.captures(content) {
            e.time_text = Some(cap.get(1).unwrap().as_str().to_string());
        }
    }

    // brand:按常见机构/平台关键词提取(可扩展为词典)
    let brands = [        ("中国银行", "中国银行"),        ("工商银行", "工商银行"),        ("建设银行", "建设银行"),        ("农业银行", "农业银行"),        ("招商银行", "招商银行"),        ("平安", "平安"),        ("支付宝", "支付宝"),        ("微信", "微信"),        ("京东", "京东"),        ("美团", "美团"),        ("顺丰", "顺丰"),        ("中通", "中通"),        ("圆通", "圆通"),        ("邮政", "邮政"),        ("12345", "12345"),    ];
    for (kw, name) in brands {
        if content.contains(kw) {
            e.brand = Some(name.to_string());
            break;
        }
    }

    e
}

src-tauri/src/rules/rule_engine.rs

use std::collections::HashMap;
use regex::Regex;

use crate::domain::dto::{RuleOutput, ExtractedEntities};
use crate::domain::enums::{Industry, SmsType};
use crate::rules::extractor::extract_entities;
use crate::rules::signals::*;

pub fn apply_rules(content: &str) -> RuleOutput {
    let mut signals: HashMap<String, serde_json::Value> = HashMap::new();
    let mut reasons: Vec<String> = vec![];
    let entities: ExtractedEntities = extract_entities(content);

    // 强模式:验证码
    let has_otp_kw = content.contains("验证码") || content.contains("校验码") || content.contains("动态码") || content.to_uppercase().contains("OTP");
    if has_otp_kw && entities.verification_code.is_some() {
        signal_bool(&mut signals, "rule_otp", true);
        reasons.push("命中强规则:验证码关键词 + 4-8位验证码".to_string());
        return RuleOutput {
            hit: true,
            industry: Some(Industry::General),
            sms_type: Some(SmsType::Otp),
            entities,
            confidence: 0.98,
            reasons,
            signals,
        };
    }

    // 强模式:物流取件(含取件码/驿站/快递到了)
    let re_pick = Regex::new(r"(取件|取货|驿站|快递已到|提货码|取件码)").unwrap();
    if re_pick.is_match(content) {
        signal_bool(&mut signals, "rule_logistics_pickup", true);
        reasons.push("命中强规则:物流取件关键词".to_string());
        return RuleOutput {
            hit: true,
            industry: Some(Industry::Channel),
            sms_type: Some(SmsType::LogisticsPickup),
            entities,
            confidence: 0.95,
            reasons,
            signals,
        };
    }

    // 强模式:显式政务机构(12345/公安/税务/社保/政务服务)
    let re_gov = Regex::new(r"(12345|公安|税务|社保|政务|政府|人民法院|检察院|交警)").unwrap();
    if re_gov.is_match(content) {
        signal_bool(&mut signals, "rule_gov", true);
        reasons.push("命中强规则:政务机构关键词".to_string());
        return RuleOutput {
            hit: true,
            industry: Some(Industry::Gov),
            sms_type: Some(SmsType::GovNotice),
            entities,
            confidence: 0.94,
            reasons,
            signals,
        };
    }

    // 强模式:银行/证券/保险 交易提醒(扣款/入账/转账/消费/余额)
    let re_fin_org = Regex::new(r"(银行|证券|信用卡|借记卡|保险|保单)").unwrap();
    let re_tx = Regex::new(r"(扣款|入账|转账|消费|交易|支付|还款|余额|可用余额)").unwrap();
    if re_fin_org.is_match(content) && re_tx.is_match(content) {
        signal_bool(&mut signals, "rule_fin_transaction", true);
        reasons.push("命中强规则:金融机构关键词 + 交易/余额关键词".to_string());
        return RuleOutput {
            hit: true,
            industry: Some(Industry::Finance),
            sms_type: Some(SmsType::Transaction),
            entities,
            confidence: 0.93,
            reasons,
            signals,
        };
    }

    // 强模式:账单催缴(欠费/逾期/应还/催缴)
    let re_bill = Regex::new(r"(欠费|逾期|应还|催缴|缴费|账单|最低还款)").unwrap();
    if re_bill.is_match(content) {
        signal_bool(&mut signals, "rule_bill_collect", true);
        reasons.push("命中强规则:账单催缴关键词".to_string());
        return RuleOutput {
            hit: true,
            industry: Some(Industry::Finance),
            sms_type: Some(SmsType::BillCollect),
            entities,
            confidence: 0.90,
            reasons,
            signals,
        };
    }

    // 未命中强规则:返回 signals + entities,交给模型补全
    signal_bool(&mut signals, "rule_hit", false);
    if entities.url.is_some() {
        signal_bool(&mut signals, "has_url", true);
        reasons.push("弱信号:包含URL".to_string());
    }
    if entities.amount.is_some() {
        signal_bool(&mut signals, "has_amount", true);
        reasons.push("弱信号:包含金额".to_string());
    }

    RuleOutput {
        hit: false,
        industry: None,
        sms_type: None,
        entities,
        confidence: 0.0,
        reasons,
        signals,
    }
}

规则测试样例(≥10 条,覆盖场景)

assets/sample_messages.csv
id,received_at,sender,phone,source,content
m1,2026-02-10 10:01:00,中国银行,95566,import,"【中国银行】您尾号1234卡于2026-02-10 09:58消费58.20元,余额1020.55元。"
m2,2026-02-10 10:02:00,支付宝,95188,import,"【支付宝】验证码 493821,用于登录验证,5分钟内有效。"
m3,2026-02-10 10:03:00,顺丰速运,95338,import,"【顺丰】快件已到达XX驿站,取件码 662913,请于18:00前取走。"
m4,2026-02-10 10:04:00,12345,12345,import,"【12345政务】您反映的问题已受理,查询进度请访问 https://gov.example.cn/track"
m5,2026-02-10 10:05:00,某运营商,10086,import,"您本月话费账单已出,应缴 89.50 元,逾期将影响服务。"
m6,2026-02-10 10:06:00,平安保险,95511,import,"【平安】您的保单将于2026-03-01到期,请及时续保,详询4008000000。"
m7,2026-02-10 10:07:00,某电商,1069xxxx,import,"【京东】会员账号绑定手机号变更成功,如非本人操作请致电950618。"
m8,2026-02-10 10:08:00,某平台,1069xxxx,import,"【美团】本店新客立减券已到账,点击 http://promo.example.com 立即使用。"
m9,2026-02-10 10:09:00,公安反诈,12110,import,"【反诈中心】警惕冒充客服退款诈骗,任何验证码均不要透露。"
m10,2026-02-10 10:10:00,未知,unknown,import,"您有一笔订单待处理,请联系 13800138000 获取详情。"

4) llama.cpp 推理 Provider 与分类 Prompt(严格 JSON、枚举约束)

src-tauri/src/providers/provider.rs

use async_trait::async_trait;
use crate::domain::dto::{ModelOutput, RuleOutput};

#[derive(Debug, Clone)]
pub struct ClassifyPayload {
    pub message_id: String,
    pub content: String,
    pub rule: RuleOutput,
    pub schema_version: String,
    pub rules_version: String,
}

#[async_trait]
pub trait Provider: Send + Sync {
    async fn classify(&self, payload: ClassifyPayload) -> anyhow::Result<ModelOutput>;
    fn name(&self) -> &'static str;
    fn model_version(&self) -> String;
}

src-tauri/src/providers/prompt.rs

use crate::domain::schema::SCHEMA_VERSION;
use serde_json::json;

pub fn build_prompt(content: &str, entities_json: &serde_json::Value, signals_json: &serde_json::Value) -> String {
    // 强约束:只允许输出严格 JSON,不要额外文本
    // 要求枚举必须从给定集合中选
    let schema = json!({
      "schema_version": SCHEMA_VERSION,
      "industry_enum": ["金融","通用","政务","渠道","互联网","其他"],
      "type_enum": ["验证码","交易提醒","账单催缴","保险续保","物流取件","会员账号变更","政务通知","风险提示","营销推广","其他"],
      "entities": {
        "brand": "string|null",
        "verification_code": "string|null",
        "amount": "number|null",
        "balance": "number|null",
        "account_suffix": "string|null",
        "time_text": "string|null",
        "url": "string|null",
        "phone_in_text": "string|null"
      }
    });

    format!(
r#"你是一个离线短信分类与结构化抽取引擎。你的任务:对短信做行业大类与类型判定,并补全实体字段。
要求:
1) 仅输出一个严格 JSON 对象,禁止输出任何多余文本。
2) industry 与 type 必须从枚举中选择,禁止出现新值。
3) entities 必须包含所有字段,缺失填 null4) confidence 为 0~1 小数。
5) reasons 为字符串数组,解释你为何做出判断,必须引用 signals / entities / content 中的信息。
6) 不要臆造链接/电话/金额;无法确定填 null 或降低 confidence。

【约束Schema】
{schema}

【短信content】
{content}

【规则层提取entities(可能不全)】
{entities}

【规则层signals(可解释性线索)】
{signals}

输出 JSON 结构如下(字段名固定):
{{
  "industry": "...",
  "type": "...",
  "entities": {{
    "brand": null,
    "verification_code": null,
    "amount": null,
    "balance": null,
    "account_suffix": null,
    "time_text": null,
    "url": null,
    "phone_in_text": null
  }},
  "confidence": 0.0,
  "reasons": ["..."]
}}"#,
        schema = schema.to_string(),
        content = content,
        entities = entities_json.to_string(),
        signals = signals_json.to_string(),
    )
}

src-tauri/src/providers/llama_cpp.rs

use std::{path::PathBuf, sync::Arc, time::Duration};
use tokio::{process::Command, sync::Semaphore, time::timeout};
use async_trait::async_trait;
use serde_json::Value;

use crate::providers::provider::{Provider, ClassifyPayload};
use crate::domain::dto::{ModelOutput, ExtractedEntities};
use crate::infra::log::append_error_log;
use crate::providers::prompt::build_prompt;

#[derive(Clone)]
pub struct LlamaCppProvider {
    pub sidecar_path: PathBuf, // llama-cli 或 llama 可执行文件
    pub model_path: PathBuf,   // GGUF
    pub threads: u32,
    pub max_concurrency: usize,
    pub timeout_ms: u64,
    pub semaphore: Arc<Semaphore>,
}

impl LlamaCppProvider {
    pub fn new(sidecar_path: PathBuf, model_path: PathBuf, threads: u32, max_concurrency: usize, timeout_ms: u64) -> Self {
        Self {
            sidecar_path,
            model_path,
            threads,
            max_concurrency,
            timeout_ms,
            semaphore: Arc::new(Semaphore::new(max_concurrency)),
        }
    }

    fn parse_model_output(&self, s: &str) -> anyhow::Result<ModelOutput> {
        // llama.cpp 可能带前后空白或多行,尽量截取第一个 JSON 对象
        let trimmed = s.trim();
        let start = trimmed.find('{').ok_or_else(|| anyhow::anyhow!("no json start"))?;
        let end = trimmed.rfind('}').ok_or_else(|| anyhow::anyhow!("no json end"))?;
        let json_str = &trimmed[start..=end];

        let v: Value = serde_json::from_str(json_str)?;
        let industry = serde_json::from_value(v.get("industry").cloned().ok_or_else(|| anyhow::anyhow!("missing industry"))?)?;
        let sms_type = serde_json::from_value(v.get("type").cloned().ok_or_else(|| anyhow::anyhow!("missing type"))?)?;
        let entities: ExtractedEntities = serde_json::from_value(v.get("entities").cloned().ok_or_else(|| anyhow::anyhow!("missing entities"))?)?;
        let confidence: f64 = v.get("confidence").and_then(|x| x.as_f64()).unwrap_or(0.5);
        let reasons: Vec<String> = v.get("reasons").and_then(|x| x.as_array())
            .map(|arr| arr.iter().filter_map(|i| i.as_str().map(|s| s.to_string())).collect())
            .unwrap_or_else(|| vec![]);

        Ok(ModelOutput {
            industry,
            sms_type,
            entities,
            confidence: confidence.clamp(0.0, 1.0),
            reasons,
            model_version: self.model_version(),
        })
    }
}

#[async_trait]
impl Provider for LlamaCppProvider {
    async fn classify(&self, payload: ClassifyPayload) -> anyhow::Result<ModelOutput> {
        let _permit = self.semaphore.acquire().await?;

        let entities_json = serde_json::to_value(&payload.rule.entities)?;
        let signals_json = serde_json::to_value(&payload.rule.signals)?;
        let prompt = build_prompt(&payload.content, &entities_json, &signals_json);

        // llama.cpp 命令行参数:根据你下载的版本可能是 llama-cli 或 llama
        // 这里采用常见参数:-m 模型 -p prompt -t threads --temp 0.2 --top-p 0.9 --ctx-size 2048
        let mut cmd = Command::new(&self.sidecar_path);
        cmd.arg("-m").arg(&self.model_path)
            .arg("-p").arg(prompt)
            .arg("-t").arg(self.threads.to_string())
            .arg("--temp").arg("0.2")
            .arg("--top-p").arg("0.9")
            .arg("--ctx-size").arg("2048");

        let dur = Duration::from_millis(self.timeout_ms);
        let out = timeout(dur, cmd.output()).await;

        match out {
            Ok(Ok(output)) => {
                let stdout = String::from_utf8_lossy(&output.stdout).to_string();
                let stderr = String::from_utf8_lossy(&output.stderr).to_string();
                if !output.status.success() {
                    append_error_log(format!("llama.cpp exit != 0: {}\nstderr={}", output.status, stderr)).ok();
                    return Err(anyhow::anyhow!("llama.cpp failed"));
                }
                // stderr 也可能有日志
                if !stderr.trim().is_empty() {
                    append_error_log(format!("llama.cpp stderr: {}", stderr)).ok();
                }
                self.parse_model_output(&stdout)
            }
            Ok(Err(e)) => {
                append_error_log(format!("llama.cpp spawn error: {}", e)).ok();
                Err(anyhow::anyhow!(e))
            }
            Err(_) => {
                append_error_log("llama.cpp timeout".to_string()).ok();
                Err(anyhow::anyhow!("timeout"))
            }
        }
    }

    fn name(&self) -> &'static str { "llama.cpp" }

    fn model_version(&self) -> String {
        // 简化:用模型文件名当版本
        self.model_path.file_name().unwrap_or_default().to_string_lossy().to_string()
    }
}

src-tauri/src/infra/log.rs

use std::{fs, io::Write, path::PathBuf};

pub fn app_log_path() -> anyhow::Result<PathBuf> {
    let base = tauri::api::path::app_log_dir(&tauri::Config::default())?;
    Ok(base.join("sms-tagging-officer.log"))
}

pub fn append_error_log(line: String) -> anyhow::Result<()> {
    let p = app_log_path()?;
    if let Some(parent) = p.parent() { fs::create_dir_all(parent)?; }
    let mut f = fs::OpenOptions::new().create(true).append(true).open(p)?;
    writeln!(f, "{}", line)?;
    Ok(())
}

5) 融合决策器(冲突处理、needs_review、置信度调节)

src-tauri/src/fusion/decision.rs

use crate::domain::dto::{FinalLabel, RuleOutput, ModelOutput, ExtractedEntities};
use crate::domain::schema::{RULES_VERSION, SCHEMA_VERSION};

fn merge_entities(rule_e: &ExtractedEntities, model_e: &ExtractedEntities) -> ExtractedEntities {
    // 规则优先:强模式常常更准;模型补全空缺字段
    ExtractedEntities {
        brand: rule_e.brand.clone().or(model_e.brand.clone()),
        verification_code: rule_e.verification_code.clone().or(model_e.verification_code.clone()),
        amount: rule_e.amount.or(model_e.amount),
        balance: rule_e.balance.or(model_e.balance),
        account_suffix: rule_e.account_suffix.clone().or(model_e.account_suffix.clone()),
        time_text: rule_e.time_text.clone().or(model_e.time_text.clone()),
        url: rule_e.url.clone().or(model_e.url.clone()),
        phone_in_text: rule_e.phone_in_text.clone().or(model_e.phone_in_text.clone()),
    }
}

pub fn fuse(message_id: &str, rule: &RuleOutput, model: Option<&ModelOutput>) -> FinalLabel {
    // 1) 规则强命中:直接用规则输出(无需模型)
    if rule.hit && rule.industry.is_some() && rule.sms_type.is_some() {
        return FinalLabel {
            message_id: message_id.to_string(),
            industry: rule.industry.clone().unwrap(),
            sms_type: rule.sms_type.clone().unwrap(),
            entities: rule.entities.clone(),
            confidence: rule.confidence.clamp(0.0, 1.0),
            reasons: rule.reasons.clone(),
            signals: rule.signals.clone(),
            needs_review: false,
            rules_version: RULES_VERSION.to_string(),
            model_version: "rule_only".to_string(),
            schema_version: SCHEMA_VERSION.to_string(),
        };
    }

    // 2) 规则未命中强模式:必须依赖模型
    let m = model.expect("model required when rule not hit");
    let mut needs_review = false;
    let mut confidence = m.confidence.clamp(0.0, 1.0);
    let mut reasons = vec![];
    reasons.extend(rule.reasons.clone());
    reasons.extend(m.reasons.clone());

    // 冲突:如果规则给了弱倾向(signals)但模型判断非常不同,可触发复核
    // 这里用简单启发:若规则提取到 otp 码/金额/链接,而模型给到类型“其他”,降低置信并进复核
    let has_otp = rule.entities.verification_code.is_some();
    let has_amount = rule.entities.amount.is_some();
    let has_url = rule.entities.url.is_some();

    if (has_otp || has_amount || has_url) && matches!(m.sms_type, crate::domain::enums::SmsType::Other) {
        needs_review = true;
        confidence = (confidence * 0.75).min(0.75);
        reasons.push("冲突:规则抽取到关键实体,但模型类型为“其他”,进入复核".to_string());
    }

    // 低置信:进入复核
    if confidence < 0.70 {
        needs_review = true;
        reasons.push("置信度低于阈值0.70,进入复核".to_string());
    }

    let entities = merge_entities(&rule.entities, &m.entities);

    FinalLabel {
        message_id: message_id.to_string(),
        industry: m.industry.clone(),
        sms_type: m.sms_type.clone(),
        entities,
        confidence,
        reasons,
        signals: rule.signals.clone(),
        needs_review,
        rules_version: RULES_VERSION.to_string(),
        model_version: m.model_version.clone(),
        schema_version: SCHEMA_VERSION.to_string(),
    }
}

6) SQLite 初始化脚本 + DAO 层(messages / labels / audit_logs)

src-tauri/src/db/migrations.sql

PRAGMA journal_mode=WAL;

CREATE TABLE IF NOT EXISTS messages (
  id TEXT PRIMARY KEY,
  content TEXT NOT NULL,
  received_at TEXT NULL,
  sender TEXT NULL,
  phone TEXT NULL,
  source TEXT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS labels (
  message_id TEXT PRIMARY KEY,
  industry TEXT NOT NULL,
  type TEXT NOT NULL,
  brand TEXT NULL,
  verification_code TEXT NULL,
  amount REAL NULL,
  balance REAL NULL,
  account_suffix TEXT NULL,
  time_text TEXT NULL,
  url TEXT NULL,
  phone_in_text TEXT NULL,
  confidence REAL NOT NULL,
  reasons_json TEXT NOT NULL,
  signals_json TEXT NOT NULL,
  needs_review INTEGER NOT NULL DEFAULT 0,
  rules_version TEXT NOT NULL,
  model_version TEXT NOT NULL,
  schema_version TEXT NOT NULL,
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
  FOREIGN KEY(message_id) REFERENCES messages(id)
);

CREATE TABLE IF NOT EXISTS audit_logs (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  message_id TEXT NOT NULL,
  operator TEXT NOT NULL,
  before_json TEXT NOT NULL,
  after_json TEXT NOT NULL,
  changed_at TEXT NOT NULL DEFAULT (datetime('now')),
  FOREIGN KEY(message_id) REFERENCES messages(id)
);

CREATE INDEX IF NOT EXISTS idx_labels_industry ON labels(industry);
CREATE INDEX IF NOT EXISTS idx_labels_type ON labels(type);
CREATE INDEX IF NOT EXISTS idx_labels_needs_review ON labels(needs_review);
CREATE INDEX IF NOT EXISTS idx_labels_confidence ON labels(confidence);

src-tauri/src/db/mod.rs

use rusqlite::Connection;
use std::path::PathBuf;

pub fn db_path() -> anyhow::Result<PathBuf> {
    let dir = tauri::api::path::app_data_dir(&tauri::Config::default())?;
    std::fs::create_dir_all(&dir)?;
    Ok(dir.join("sms-tagging-officer.sqlite"))
}

pub fn connect() -> anyhow::Result<Connection> {
    let p = db_path()?;
    Ok(Connection::open(p)?)
}

pub fn migrate(conn: &Connection) -> anyhow::Result<()> {
    let sql = include_str!("migrations.sql");
    conn.execute_batch(sql)?;
    Ok(())
}

src-tauri/src/db/dao.rs

use rusqlite::{params, Connection};
use serde_json::Value;

use crate::domain::dto::{FinalLabel};
use crate::domain::enums::{Industry, SmsType};

#[derive(Debug, Clone)]
pub struct MessageRow {
    pub id: String,
    pub content: String,
    pub received_at: Option<String>,
    pub sender: Option<String>,
    pub phone: Option<String>,
    pub source: Option<String>,
}

pub fn upsert_messages(conn: &Connection, rows: &[MessageRow]) -> anyhow::Result<usize> {
    let tx = conn.transaction()?;
    let mut count = 0usize;
    for r in rows {
        tx.execute(
            r#"INSERT INTO messages (id, content, received_at, sender, phone, source)
               VALUES (?1, ?2, ?3, ?4, ?5, ?6)
               ON CONFLICT(id) DO UPDATE SET
                 content=excluded.content,
                 received_at=excluded.received_at,
                 sender=excluded.sender,
                 phone=excluded.phone,
                 source=excluded.source"#,
            params![r.id, r.content, r.received_at, r.sender, r.phone, r.source],
        )?;
        count += 1;
    }
    tx.commit()?;
    Ok(count)
}

pub fn upsert_label(conn: &Connection, label: &FinalLabel) -> anyhow::Result<()> {
    let reasons_json = serde_json::to_string(&label.reasons)?;
    let signals_json = serde_json::to_string(&label.signals)?;
    conn.execute(
        r#"INSERT INTO labels (
            message_id, industry, type,
            brand, verification_code, amount, balance, account_suffix, time_text, url, phone_in_text,
            confidence, reasons_json, signals_json, needs_review,
            rules_version, model_version, schema_version, updated_at
        ) VALUES (
            ?1, ?2, ?3,
            ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11,
            ?12, ?13, ?14, ?15,
            ?16, ?17, ?18, datetime('now')
        )
        ON CONFLICT(message_id) DO UPDATE SET
            industry=excluded.industry,
            type=excluded.type,
            brand=excluded.brand,
            verification_code=excluded.verification_code,
            amount=excluded.amount,
            balance=excluded.balance,
            account_suffix=excluded.account_suffix,
            time_text=excluded.time_text,
            url=excluded.url,
            phone_in_text=excluded.phone_in_text,
            confidence=excluded.confidence,
            reasons_json=excluded.reasons_json,
            signals_json=excluded.signals_json,
            needs_review=excluded.needs_review,
            rules_version=excluded.rules_version,
            model_version=excluded.model_version,
            schema_version=excluded.schema_version,
            updated_at=datetime('now')"#,
        params![
            label.message_id,
            industry_to_str(&label.industry),
            type_to_str(&label.sms_type),
            label.entities.brand,
            label.entities.verification_code,
            label.entities.amount,
            label.entities.balance,
            label.entities.account_suffix,
            label.entities.time_text,
            label.entities.url,
            label.entities.phone_in_text,
            label.confidence,
            reasons_json,
            signals_json,
            if label.needs_review { 1 } else { 0 },
            label.rules_version,
            label.model_version,
            label.schema_version,
        ],
    )?;
    Ok(())
}

pub fn get_label_json(conn: &Connection, message_id: &str) -> anyhow::Result<Option<Value>> {
    let mut stmt = conn.prepare(r#"SELECT
        industry, type, brand, verification_code, amount, balance, account_suffix, time_text, url, phone_in_text,
        confidence, reasons_json, signals_json, needs_review, rules_version, model_version, schema_version
      FROM labels WHERE message_id=?1"#)?;
    let mut rows = stmt.query(params![message_id])?;
    if let Some(r) = rows.next()? {
        let reasons_json: String = r.get(11)?;
        let signals_json: String = r.get(12)?;
        let v = serde_json::json!({
          "message_id": message_id,
          "industry": r.get::<_, String>(0)?,
          "type": r.get::<_, String>(1)?,
          "entities": {
            "brand": r.get::<_, Option<String>>(2)?,
            "verification_code": r.get::<_, Option<String>>(3)?,
            "amount": r.get::<_, Option<f64>>(4)?,
            "balance": r.get::<_, Option<f64>>(5)?,
            "account_suffix": r.get::<_, Option<String>>(6)?,
            "time_text": r.get::<_, Option<String>>(7)?,
            "url": r.get::<_, Option<String>>(8)?,
            "phone_in_text": r.get::<_, Option<String>>(9)?,
          },
          "confidence": r.get::<_, f64>(10)?,
          "reasons": serde_json::from_str::<Value>(&reasons_json).unwrap_or(Value::Array(vec![])),
          "signals": serde_json::from_str::<Value>(&signals_json).unwrap_or(Value::Object(Default::default())),
          "needs_review": r.get::<_, i64>(13)? == 1,
          "rules_version": r.get::<_, String>(14)?,
          "model_version": r.get::<_, String>(15)?,
          "schema_version": r.get::<_, String>(16)?,
        });
        return Ok(Some(v));
    }
    Ok(None)
}

pub fn insert_audit_log(conn: &Connection, message_id: &str, operator: &str, before_json: &Value, after_json: &Value) -> anyhow::Result<()> {
    conn.execute(
        r#"INSERT INTO audit_logs (message_id, operator, before_json, after_json)
           VALUES (?1, ?2, ?3, ?4)"#,
        params![
            message_id,
            operator,
            before_json.to_string(),
            after_json.to_string()
        ],
    )?;
    Ok(())
}

fn industry_to_str(i: &Industry) -> &'static str {
    match i {
        Industry::Finance => "金融",
        Industry::General => "通用",
        Industry::Gov => "政务",
        Industry::Channel => "渠道",
        Industry::Internet => "互联网",
        Industry::Other => "其他",
    }
}

fn type_to_str(t: &SmsType) -> &'static str {
    match t {
        SmsType::Otp => "验证码",
        SmsType::Transaction => "交易提醒",
        SmsType::BillCollect => "账单催缴",
        SmsType::InsuranceRenew => "保险续保",
        SmsType::LogisticsPickup => "物流取件",
        SmsType::AccountChange => "会员账号变更",
        SmsType::GovNotice => "政务通知",
        SmsType::RiskAlert => "风险提示",
        SmsType::Marketing => "营销推广",
        SmsType::Other => "其他",
    }
}

7) 批处理队列(并发/超时/重试/不卡 UI)+ Tauri Commands

src-tauri/src/batch/worker.rs

use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use serde_json::Value;

use crate::{db, rules, providers::provider::{Provider, ClassifyPayload}, fusion};
use crate::infra::log::append_error_log;

#[derive(Debug, Clone)]
pub struct BatchOptions {
    pub only_unlabeled: bool,
    pub only_needs_review: bool,
    pub max_retries: u8,
}

#[derive(Debug, Clone)]
pub struct BatchProgress {
    pub total: usize,
    pub done: usize,
    pub failed: usize,
    pub current_id: Option<String>,
}

pub struct BatchState {
    pub running: bool,
    pub progress: BatchProgress,
}

pub type SharedBatchState = Arc<Mutex<BatchState>>;

pub async fn run_batch(
    app: tauri::AppHandle,
    provider: Arc<dyn Provider>,
    message_ids: Vec<String>,
    options: BatchOptions,
    state: SharedBatchState,
) -> anyhow::Result<()> {
    {
        let mut s = state.lock().unwrap();
        s.running = true;
        s.progress = BatchProgress { total: message_ids.len(), done: 0, failed: 0, current_id: None };
    }

    let (tx, mut rx) = mpsc::channel::<(String, anyhow::Result<Value>)>(64);

    // worker producer:并发投递,每条短信独立重试
    for id in message_ids.clone() {
        let txc = tx.clone();
        let prov = provider.clone();
        let appc = app.clone();
        tokio::spawn(async move {
            let res = process_one(appc, prov, &id, &options).await;
            let _ = txc.send((id, res)).await;
        });
    }
    drop(tx);

    while let Some((id, res)) = rx.recv().await {
        let mut emit_payload = serde_json::json!({"id": id, "ok": true});
        match res {
            Ok(label_json) => {
                emit_payload["label"] = label_json;
                let mut s = state.lock().unwrap();
                s.progress.done += 1;
                s.progress.current_id = None;
            }
            Err(e) => {
                append_error_log(format!("batch item failed id={} err={}", id, e)).ok();
                emit_payload["ok"] = serde_json::json!(false);
                emit_payload["error"] = serde_json::json!(e.to_string());
                let mut s = state.lock().unwrap();
                s.progress.failed += 1;
                s.progress.done += 1;
                s.progress.current_id = None;
            }
        }

        // 推送进度到前端
        let s = state.lock().unwrap().progress.clone();
        let _ = app.emit_all("batch_progress", serde_json::json!({
            "total": s.total,
            "done": s.done,
            "failed": s.failed,
            "current_id": s.current_id,
            "event": emit_payload
        }));
    }

    {
        let mut s = state.lock().unwrap();
        s.running = false;
    }
    Ok(())
}

async fn process_one(
    _app: tauri::AppHandle,
    provider: Arc<dyn Provider>,
    message_id: &str,
    options: &BatchOptions,
) -> anyhow::Result<Value> {
    let conn = db::connect()?;
    db::migrate(&conn)?;

    // 查询 content
    let mut stmt = conn.prepare("SELECT content FROM messages WHERE id=?1")?;
    let content: String = stmt.query_row([message_id], |r| r.get(0))?;

    // 过滤:only_unlabeled / only_needs_review
    if options.only_unlabeled {
        let mut s2 = conn.prepare("SELECT COUNT(1) FROM labels WHERE message_id=?1")?;
        let cnt: i64 = s2.query_row([message_id], |r| r.get(0))?;
        if cnt > 0 { return Ok(serde_json::json!({"skipped": true})); }
    }
    if options.only_needs_review {
        let mut s3 = conn.prepare("SELECT needs_review FROM labels WHERE message_id=?1")?;
        let v = s3.query_row([message_id], |r| r.get::<_, i64>(0)).ok();
        if v != Some(1) { return Ok(serde_json::json!({"skipped": true})); }
    }

    let rule = rules::rule_engine::apply_rules(&content);

    // 规则强命中:直接融合(rule_only)
    if rule.hit && rule.industry.is_some() && rule.sms_type.is_some() {
        let final_label = fusion::decision::fuse(message_id, &rule, None);
        crate::db::dao::upsert_label(&conn, &final_label)?;
        return Ok(crate::db::dao::get_label_json(&conn, message_id)?.unwrap());
    }

    // 模型层:重试
    let mut last_err: Option<anyhow::Error> = None;
    for _ in 0..=options.max_retries {
        let payload = ClassifyPayload {
            message_id: message_id.to_string(),
            content: content.clone(),
            rule: rule.clone(),
            schema_version: crate::domain::schema::SCHEMA_VERSION.to_string(),
            rules_version: crate::domain::schema::RULES_VERSION.to_string(),
        };
        match provider.classify(payload).await {
            Ok(mo) => {
                let final_label = fusion::decision::fuse(message_id, &rule, Some(&mo));
                crate::db::dao::upsert_label(&conn, &final_label)?;
                return Ok(crate::db::dao::get_label_json(&conn, message_id)?.unwrap());
            }
            Err(e) => last_err = Some(e),
        }
    }
    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("unknown classify error")))
}

src-tauri/src/commands.rs

use std::{path::PathBuf, sync::{Arc, Mutex}};
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::{db, db::dao::{MessageRow, upsert_messages, get_label_json, insert_audit_log}, providers::llama_cpp::LlamaCppProvider, providers::provider::Provider, batch::{worker, worker::{SharedBatchState, BatchState, BatchOptions}}};

#[derive(Debug, Deserialize)]
pub struct ImportRequest {
    pub rows: Vec<MessageRowReq>,
}

#[derive(Debug, Deserialize)]
pub struct MessageRowReq {
    pub id: String,
    pub content: String,
    pub received_at: Option<String>,
    pub sender: Option<String>,
    pub phone: Option<String>,
    pub source: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct ImportResponse {
    pub inserted: usize,
}

#[tauri::command]
pub fn db_init() -> Result<(), String> {
    let conn = db::connect().map_err(|e| e.to_string())?;
    db::migrate(&conn).map_err(|e| e.to_string())?;
    Ok(())
}

#[tauri::command]
pub fn import_messages(req: ImportRequest) -> Result<ImportResponse, String> {
    let conn = db::connect().map_err(|e| e.to_string())?;
    db::migrate(&conn).map_err(|e| e.to_string())?;

    let rows: Vec<MessageRow> = req.rows.into_iter().map(|r| MessageRow {
        id: r.id,
        content: r.content,
        received_at: r.received_at,
        sender: r.sender,
        phone: r.phone,
        source: r.source,
    }).collect();

    let inserted = upsert_messages(&conn, &rows).map_err(|e| e.to_string())?;
    Ok(ImportResponse { inserted })
}

#[tauri::command]
pub fn get_label(message_id: String) -> Result<Option<Value>, String> {
    let conn = db::connect().map_err(|e| e.to_string())?;
    db::migrate(&conn).map_err(|e| e.to_string())?;
    get_label_json(&conn, &message_id).map_err(|e| e.to_string())
}

#[derive(Debug, Deserialize)]
pub struct SaveReviewRequest {
    pub message_id: String,
    pub operator: String,
    pub after: Value,
}

#[tauri::command]
pub fn save_review(req: SaveReviewRequest) -> Result<(), String> {
    let conn = db::connect().map_err(|e| e.to_string())?;
    db::migrate(&conn).map_err(|e| e.to_string())?;
    let before = get_label_json(&conn, &req.message_id).map_err(|e| e.to_string())?
        .unwrap_or(Value::Null);

    // 直接写 labels:这里复用 JSON 写入策略(简化:前端传字段齐全)
    // 生产版可改为结构体反序列化,进一步强校验
    let a = &req.after;
    conn.execute(
        r#"INSERT INTO labels (
            message_id, industry, type,
            brand, verification_code, amount, balance, account_suffix, time_text, url, phone_in_text,
            confidence, reasons_json, signals_json, needs_review,
            rules_version, model_version, schema_version, updated_at
        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, datetime('now'))
        ON CONFLICT(message_id) DO UPDATE SET
            industry=excluded.industry,
            type=excluded.type,
            brand=excluded.brand,
            verification_code=excluded.verification_code,
            amount=excluded.amount,
            balance=excluded.balance,
            account_suffix=excluded.account_suffix,
            time_text=excluded.time_text,
            url=excluded.url,
            phone_in_text=excluded.phone_in_text,
            confidence=excluded.confidence,
            reasons_json=excluded.reasons_json,
            signals_json=excluded.signals_json,
            needs_review=excluded.needs_review,
            rules_version=excluded.rules_version,
            model_version=excluded.model_version,
            schema_version=excluded.schema_version,
            updated_at=datetime('now')"#,
        rusqlite::params![
            req.message_id,
            a["industry"].as_str().unwrap_or("其他"),
            a["type"].as_str().unwrap_or("其他"),
            a["entities"]["brand"].as_str(),
            a["entities"]["verification_code"].as_str(),
            a["entities"]["amount"].as_f64(),
            a["entities"]["balance"].as_f64(),
            a["entities"]["account_suffix"].as_str(),
            a["entities"]["time_text"].as_str(),
            a["entities"]["url"].as_str(),
            a["entities"]["phone_in_text"].as_str(),
            a["confidence"].as_f64().unwrap_or(0.5),
            a["reasons"].to_string(),
            a["signals"].to_string(),
            if a["needs_review"].as_bool().unwrap_or(false) { 1 } else { 0 },
            a["rules_version"].as_str().unwrap_or("1.0.0"),
            a["model_version"].as_str().unwrap_or("manual"),
            a["schema_version"].as_str().unwrap_or("1.0.0"),
        ],
    ).map_err(|e| e.to_string())?;

    insert_audit_log(&conn, &req.message_id, &req.operator, &before, &req.after).map_err(|e| e.to_string())?;
    Ok(())
}

#[derive(Debug, Deserialize)]
pub struct ProviderConfig {
    pub sidecar_path: String,
    pub model_path: String,
    pub threads: u32,
    pub max_concurrency: usize,
    pub timeout_ms: u64,
}

#[tauri::command]
pub async fn start_batch(
    app: tauri::AppHandle,
    provider_cfg: ProviderConfig,
    message_ids: Vec<String>,
    only_unlabeled: bool,
    only_needs_review: bool,
) -> Result<(), String> {
    let provider = LlamaCppProvider::new(
        PathBuf::from(provider_cfg.sidecar_path),
        PathBuf::from(provider_cfg.model_path),
        provider_cfg.threads,
        provider_cfg.max_concurrency,
        provider_cfg.timeout_ms,
    );
    let provider_arc: Arc<dyn Provider> = Arc::new(provider);

    let state: SharedBatchState = app.state::<SharedBatchState>().inner().clone();

    let opts = BatchOptions {
        only_unlabeled,
        only_needs_review,
        max_retries: 1,
    };

    worker::run_batch(app, provider_arc, message_ids, opts, state)
        .await
        .map_err(|e| e.to_string())
}

src-tauri/src/main.rs

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

mod commands;
mod domain;
mod infra;
mod db;
mod rules;
mod providers;
mod fusion;
mod batch;

use std::sync::{Arc, Mutex};
use batch::worker::{SharedBatchState, BatchState, BatchProgress};

fn main() {
    let initial_state: SharedBatchState = Arc::new(Mutex::new(BatchState {
        running: false,
        progress: BatchProgress { total: 0, done: 0, failed: 0, current_id: None },
    }));

    tauri::Builder::default()
        .manage(initial_state)
        .invoke_handler(tauri::generate_handler![
            commands::db_init,
            commands::import_messages,
            commands::get_label,
            commands::save_review,
            commands::start_batch,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

src-tauri/Cargo.toml

[package]
name = "sms-tagging-officer"
version = "0.1.0"
edition = "2021"

[build-dependencies]
tauri-build = { version = "1.5", features = [] }

[dependencies]
tauri = { version = "1.5", features = ["path-all", "fs-all", "shell-open"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rusqlite = { version = "0.31", features = ["bundled"] }
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "time", "process", "sync"] }
anyhow = "1.0"
regex = "1.10"
async-trait = "0.1"

8) 前端页面与核心组件(导入 / 批处理 / 列表过滤 / 复核抽屉 / 导出 / 设置)

apps/desktop/src/api/tauri.ts

import { invoke } from "@tauri-apps/api/tauri";
import type { LabelResult } from "./schema";

export async function dbInit() {
  return invoke<void>("db_init");
}

export async function importMessages(rows: any[]) {
  return invoke<{ inserted: number }>("import_messages", { req: { rows } });
}

export async function getLabel(messageId: string) {
  return invoke<LabelResult | null>("get_label", { messageId });
}

export async function saveReview(payload: { message_id: string; operator: string; after: any }) {
  return invoke<void>("save_review", { req: payload });
}

export async function startBatch(args: {
  provider_cfg: any;
  message_ids: string[];
  only_unlabeled: boolean;
  only_needs_review: boolean;
}) {
  return invoke<void>("start_batch", args);
}

apps/desktop/src/api/types.ts

export type MessageRow = {
  id: string;
  content: string;
  received_at?: string | null;
  sender?: string | null;
  phone?: string | null;
  source?: string | null;
};

apps/desktop/src/stores/settings.ts

import { defineStore } from "pinia";

export const useSettingsStore = defineStore("settings", {
  state: () => ({
    operator: "default",
    llamaSidecarPath: "",
    modelPath: "",
    threads: 4,
    maxConcurrency: 2,
    timeoutMs: 15000,
  }),
  actions: {
    load() {
      const raw = localStorage.getItem("sms_officer_settings");
      if (raw) Object.assign(this.$state, JSON.parse(raw));
    },
    save() {
      localStorage.setItem("sms_officer_settings", JSON.stringify(this.$state));
    },
  },
});

apps/desktop/src/stores/batch.ts

import { defineStore } from "pinia";

export const useBatchStore = defineStore("batch", {
  state: () => ({
    total: 0,
    done: 0,
    failed: 0,
    lastEvent: null as any,
    running: false,
  }),
  actions: {
    reset() {
      this.total = 0; this.done = 0; this.failed = 0; this.lastEvent = null; this.running = false;
    },
  },
});

apps/desktop/src/router.ts

import { createRouter, createWebHashHistory } from "vue-router";
import ImportPage from "./pages/ImportPage.vue";
import BatchPage from "./pages/BatchPage.vue";
import ListPage from "./pages/ListPage.vue";
import ExportPage from "./pages/ExportPage.vue";
import SettingsPage from "./pages/SettingsPage.vue";

export const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    { path: "/", redirect: "/import" },
    { path: "/import", component: ImportPage },
    { path: "/batch", component: BatchPage },
    { path: "/list", component: ListPage },
    { path: "/export", component: ExportPage },
    { path: "/settings", component: SettingsPage },
  ],
});

apps/desktop/src/main.ts

import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import { router } from "./router";

createApp(App).use(createPinia()).use(router).mount("#app");

apps/desktop/src/App.vue

<template>
  <div class="app">
    <aside class="nav">
      <h2>短信智标官</h2>
      <nav>
        <RouterLink to="/import">导入</RouterLink>
        <RouterLink to="/batch">批处理</RouterLink>
        <RouterLink to="/list">列表复核</RouterLink>
        <RouterLink to="/export">导出</RouterLink>
        <RouterLink to="/settings">设置</RouterLink>
      </nav>
    </aside>
    <main class="main">
      <RouterView />
    </main>
  </div>
</template>

<style scoped>
.app { display: grid; grid-template-columns: 220px 1fr; height: 100vh; }
.nav { border-right: 1px solid #eee; padding: 16px; }
.nav nav { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
.main { padding: 16px; overflow: auto; }
a.router-link-active { font-weight: 700; }
</style>

导入页:CSV/Excel 列映射 + 写入 messages

apps/desktop/src/pages/ImportPage.vue
<template>
  <section>
    <h3>导入数据</h3>
    <p>支持 CSV / Excel。先选择文件,再进行列映射,然后导入到本地 SQLite。</p>

    <div class="row">
      <input type="file" @change="onFile" />
      <button @click="loadSample">加载内置样例</button>
      <button @click="doImport" :disabled="rows.length===0">导入({{ rows.length }}条)</button>
    </div>

    <ColumnMapper
      v-if="headers.length"
      :headers="headers"
      v-model:mapping="mapping"
    />

    <pre class="preview" v-if="rows.length">{{ rows.slice(0,3) }}</pre>
    <div v-if="msg" class="msg">{{ msg }}</div>
  </section>
</template>

<script setup lang="ts">
import * as Papa from "papaparse";
import * as XLSX from "xlsx";
import { ref } from "vue";
import ColumnMapper from "../components/ColumnMapper.vue";
import { dbInit, importMessages } from "../api/tauri";
import { buildSampleRows } from "../utils/sample";
import type { MessageRow } from "../api/types";

const headers = ref<string[]>([]);
const rows = ref<any[]>([]);
const msg = ref("");

const mapping = ref<Record<string, string>>({
  id: "id",
  content: "content",
  received_at: "received_at",
  sender: "sender",
  phone: "phone",
  source: "source",
});

async function onFile(e: Event) {
  msg.value = "";
  const file = (e.target as HTMLInputElement).files?.[0];
  if (!file) return;

  const name = file.name.toLowerCase();
  if (name.endsWith(".csv")) {
    const text = await file.text();
    const parsed = Papa.parse(text, { header: true, skipEmptyLines: true });
    headers.value = (parsed.meta.fields || []) as string[];
    rows.value = parsed.data as any[];
  } else if (name.endsWith(".xlsx") || name.endsWith(".xls")) {
    const buf = await file.arrayBuffer();
    const wb = XLSX.read(buf);
    const sheet = wb.Sheets[wb.SheetNames[0]];
    const json = XLSX.utils.sheet_to_json(sheet, { defval: "" }) as any[];
    headers.value = Object.keys(json[0] || {});
    rows.value = json;
  } else {
    msg.value = "仅支持 CSV / Excel";
  }
}

function loadSample() {
  const s = buildSampleRows();
  headers.value = Object.keys(s[0]);
  rows.value = s;
}

async function doImport() {
  await dbInit();

  const mapped: MessageRow[] = rows.value.map((r) => ({
    id: String(r[mapping.value.id] ?? "").trim(),
    content: String(r[mapping.value.content] ?? "").trim(),
    received_at: r[mapping.value.received_at] ? String(r[mapping.value.received_at]) : null,
    sender: r[mapping.value.sender] ? String(r[mapping.value.sender]) : null,
    phone: r[mapping.value.phone] ? String(r[mapping.value.phone]) : null,
    source: r[mapping.value.source] ? String(r[mapping.value.source]) : "import",
  })).filter(x => x.id && x.content);

  const res = await importMessages(mapped);
  msg.value = `导入完成:${res.inserted} 条`;
}
</script>

<style scoped>
.row { display: flex; gap: 10px; align-items: center; margin: 10px 0; }
.preview { background: #fafafa; border: 1px solid #eee; padding: 10px; }
.msg { margin-top: 10px; color: #0a7; }
</style>
apps/desktop/src/components/ColumnMapper.vue
<template>
  <div class="mapper">
    <h4>列映射</h4>
    <div class="grid">
      <label>id</label>
      <select v-model="local.id"><option v-for="h in headers" :key="h" :value="h">{{ h }}</option></select>

      <label>content</label>
      <select v-model="local.content"><option v-for="h in headers" :key="h" :value="h">{{ h }}</option></select>

      <label>received_at</label>
      <select v-model="local.received_at"><option value="">(空)</option><option v-for="h in headers" :key="h" :value="h">{{ h }}</option></select>

      <label>sender</label>
      <select v-model="local.sender"><option value="">(空)</option><option v-for="h in headers" :key="h" :value="h">{{ h }}</option></select>

      <label>phone</label>
      <select v-model="local.phone"><option value="">(空)</option><option v-for="h in headers" :key="h" :value="h">{{ h }}</option></select>

      <label>source</label>
      <select v-model="local.source"><option value="">(空)</option><option v-for="h in headers" :key="h" :value="h">{{ h }}</option></select>
    </div>
  </div>
</template>

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

const props = defineProps<{ headers: string[]; mapping: Record<string,string> }>();
const emit = defineEmits<{ (e:"update:mapping", v: Record<string,string>): void }>();

const local = computed({
  get: () => props.mapping,
  set: (v) => emit("update:mapping", v),
});
</script>

<style scoped>
.mapper { border: 1px solid #eee; padding: 12px; border-radius: 8px; margin: 12px 0; }
.grid { display: grid; grid-template-columns: 140px 1fr; gap: 8px; align-items: center; }
select { width: 100%; }
</style>

批处理页:进度条、失败计数、重试、增量选项

apps/desktop/src/pages/BatchPage.vue
<template>
  <section>
    <h3>批处理</h3>

    <div class="panel">
      <label><input type="checkbox" v-model="onlyUnlabeled" /> 只跑未标注</label>
      <label><input type="checkbox" v-model="onlyNeedsReview" /> 只跑 needs_review</label>
      <button @click="start" :disabled="running">开始</button>
    </div>

    <ProgressPanel
      :total="total"
      :done="done"
      :failed="failed"
      :running="running"
      :lastEvent="lastEvent"
    />
  </section>
</template>

<script setup lang="ts">
import { onMounted, ref } from "vue";
import { listen } from "@tauri-apps/api/event";
import ProgressPanel from "../components/ProgressPanel.vue";
import { useSettingsStore } from "../stores/settings";
import { startBatch, dbInit } from "../api/tauri";

const settings = useSettingsStore();
settings.load();

const onlyUnlabeled = ref(true);
const onlyNeedsReview = ref(false);

const total = ref(0);
const done = ref(0);
const failed = ref(0);
const running = ref(false);
const lastEvent = ref<any>(null);

onMounted(async () => {
  await dbInit();
  await listen("batch_progress", (e) => {
    const p: any = e.payload;
    total.value = p.total;
    done.value = p.done;
    failed.value = p.failed;
    lastEvent.value = p.event;
    if (done.value >= total.value) running.value = false;
  });
});

async function start() {
  running.value = true;
  total.value = 0; done.value = 0; failed.value = 0; lastEvent.value = null;

  // 这里简化:前端传一个 message_ids 列表
  // 生产版:增加后端接口 query_message_ids(filters)
  // 先用样例:m1..m10
  const ids = Array.from({ length: 10 }).map((_, i) => `m${i + 1}`);

  await startBatch({
    provider_cfg: {
      sidecar_path: settings.llamaSidecarPath,
      model_path: settings.modelPath,
      threads: settings.threads,
      max_concurrency: settings.maxConcurrency,
      timeout_ms: settings.timeoutMs,
    },
    message_ids: ids,
    only_unlabeled: onlyUnlabeled.value,
    only_needs_review: onlyNeedsReview.value,
  });
}
</script>

<style scoped>
.panel { display: flex; gap: 16px; align-items: center; padding: 12px; border: 1px solid #eee; border-radius: 8px; }
</style>
apps/desktop/src/components/ProgressPanel.vue
<template>
  <div class="box">
    <div class="bar">
      <div class="fill" :style="{ width: pct + '%' }"></div>
    </div>
    <div class="meta">
      <div>总数:{{ total }},完成:{{ done }},失败:{{ failed }}</div>
      <div v-if="running">处理中…</div>
      <div v-else>空闲</div>
    </div>

    <pre v-if="lastEvent" class="event">{{ lastEvent }}</pre>
  </div>
</template>

<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{ total: number; done: number; failed: number; running: boolean; lastEvent: any }>();
const pct = computed(() => props.total ? Math.floor((props.done / props.total) * 100) : 0);
</script>

<style scoped>
.box { margin-top: 12px; border: 1px solid #eee; border-radius: 8px; padding: 12px; }
.bar { height: 10px; background: #f0f0f0; border-radius: 999px; overflow: hidden; }
.fill { height: 10px; background: #3b82f6; }
.meta { display: flex; justify-content: space-between; margin-top: 10px; color: #333; }
.event { background: #fafafa; border: 1px solid #eee; padding: 10px; margin-top: 10px; }
</style>

列表页 + 复核抽屉(这里给“可跑通的核心闭环”)

列表过滤/导出全量查询接口较长,我在 README 里给你扩展点;这版先把“导入→批处理→单条复核保存→审计落库→导出”跑通。

apps/desktop/src/pages/ListPage.vue
<template>
  <section>
    <h3>列表复核</h3>
    <p>输入 message_id 直接打开复核抽屉(演示闭环)。生产版在此页接入后端分页查询与过滤。</p>

    <div class="row">
      <input v-model="id" placeholder="例如 m1" />
      <button @click="open">打开</button>
    </div>

    <ReviewDrawer v-if="label" :label="label" @close="label=null" @save="save" />
    <div v-if="err" class="err">{{ err }}</div>
  </section>
</template>

<script setup lang="ts">
import { ref } from "vue";
import ReviewDrawer from "../components/ReviewDrawer.vue";
import { getLabel, saveReview } from "../api/tauri";
import { useSettingsStore } from "../stores/settings";

const settings = useSettingsStore(); settings.load();

const id = ref("m1");
const label = ref<any>(null);
const err = ref("");

async function open() {
  err.value = "";
  const v = await getLabel(id.value);
  if (!v) {
    err.value = "未找到标签(先去批处理页跑一遍)";
    return;
  }
  label.value = v;
}

async function save(after: any) {
  await saveReview({ message_id: after.message_id, operator: settings.operator, after });
  label.value = await getLabel(after.message_id);
}
</script>

<style scoped>
.row { display:flex; gap:10px; align-items:center; }
.err { color:#c00; margin-top:10px; }
</style>
apps/desktop/src/components/ReviewDrawer.vue
<template>
  <div class="mask">
    <div class="drawer">
      <header>
        <h4>复核:{{ local.message_id }}</h4>
        <button @click="$emit('close')">关闭</button>
      </header>

      <div class="field">
        <label>industry</label>
        <select v-model="local.industry">
          <option v-for="x in industryEnum" :key="x" :value="x">{{ x }}</option>
        </select>
      </div>

      <div class="field">
        <label>type</label>
        <select v-model="local.type">
          <option v-for="x in typeEnum" :key="x" :value="x">{{ x }}</option>
        </select>
      </div>

      <div class="field">
        <label>confidence</label>
        <input type="number" step="0.01" v-model.number="local.confidence" />
      </div>

      <h5>entities</h5>
      <div class="grid">
        <label>brand</label><input v-model="local.entities.brand" placeholder="null 或字符串" />
        <label>verification_code</label><input v-model="local.entities.verification_code" />
        <label>amount</label><input v-model="amountText" />
        <label>balance</label><input v-model="balanceText" />
        <label>account_suffix</label><input v-model="local.entities.account_suffix" />
        <label>time_text</label><input v-model="local.entities.time_text" />
        <label>url</label><input v-model="local.entities.url" />
        <label>phone_in_text</label><input v-model="local.entities.phone_in_text" />
      </div>

      <div class="field">
        <label>needs_review</label>
        <input type="checkbox" v-model="local.needs_review" />
      </div>

      <h5>reasons</h5>
      <textarea v-model="reasonsText" rows="4"></textarea>

      <footer>
        <button class="primary" @click="doSave">保存</button>
      </footer>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, reactive } from "vue";
import { INDUSTRY_ENUM, TYPE_ENUM } from "../api/schema";

const props = defineProps<{ label: any }>();
const emit = defineEmits<{ (e:"close"): void; (e:"save", after: any): void }>();

const local = reactive(JSON.parse(JSON.stringify(props.label)));

const industryEnum = INDUSTRY_ENUM as unknown as string[];
const typeEnum = TYPE_ENUM as unknown as string[];

const amountText = computed({
  get: () => local.entities.amount == null ? "" : String(local.entities.amount),
  set: (v) => local.entities.amount = v.trim() ? Number(v) : null,
});
const balanceText = computed({
  get: () => local.entities.balance == null ? "" : String(local.entities.balance),
  set: (v) => local.entities.balance = v.trim() ? Number(v) : null,
});
const reasonsText = computed({
  get: () => (local.reasons || []).join("\n"),
  set: (v) => local.reasons = v.split("\n").map(s => s.trim()).filter(Boolean),
});

function doSave() {
  // 维持字段齐全
  emit("save", local);
}
</script>

<style scoped>
.mask { position: fixed; inset: 0; background: rgba(0,0,0,0.25); display:flex; justify-content:flex-end; }
.drawer { width: 520px; height: 100%; background: #fff; padding: 14px; overflow:auto; }
header { display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #eee; padding-bottom:8px; }
.field { display:grid; grid-template-columns: 140px 1fr; gap: 8px; margin: 10px 0; align-items:center; }
.grid { display:grid; grid-template-columns: 140px 1fr; gap: 8px; }
footer { margin-top: 12px; display:flex; justify-content:flex-end; }
.primary { background:#3b82f6; color:#fff; border:none; padding: 8px 12px; border-radius: 6px; }
</style>

设置页:模型路径选择 + 健康检查(这里实现为“前端填写路径 + 批处理时由后端报错”)

apps/desktop/src/pages/SettingsPage.vue
<template>
  <section>
    <h3>设置</h3>
    <p>本机离线推理:填写 llama.cpp 可执行文件路径 与 GGUF 模型路径。</p>

    <div class="grid">
      <label>operator</label>
      <input v-model="s.operator" />

      <label>llama sidecar path</label>
      <input v-model="s.llamaSidecarPath" placeholder="例如 C:...\llama-cli.exe 或 /usr/local/bin/llama" />

      <label>gguf model path</label>
      <input v-model="s.modelPath" placeholder="例如 D:\models\qwen2.5-1.5b-instruct-q4.gguf" />

      <label>threads</label>
      <input type="number" v-model.number="s.threads" />

      <label>max concurrency</label>
      <input type="number" v-model.number="s.maxConcurrency" />

      <label>timeout(ms)</label>
      <input type="number" v-model.number="s.timeoutMs" />
    </div>

    <div class="row">
      <button @click="save">保存</button>
      <span v-if="msg" class="msg">{{ msg }}</span>
    </div>
  </section>
</template>

<script setup lang="ts">
import { useSettingsStore } from "../stores/settings";
import { ref } from "vue";

const s = useSettingsStore(); s.load();
const msg = ref("");

function save() {
  s.save();
  msg.value = "已保存";
  setTimeout(() => msg.value = "", 1200);
}
</script>

<style scoped>
.grid { display:grid; grid-template-columns: 180px 1fr; gap: 10px; align-items:center; max-width: 900px; }
.row { margin-top: 12px; display:flex; gap: 10px; align-items:center; }
.msg { color:#0a7; }
</style>

导出页:导出 JSONL / CSV(演示版:导出 sample,生产版接入后端查询并写文件)

apps/desktop/src/pages/ExportPage.vue
<template>
  <section>
    <h3>导出</h3>
    <p>演示版:导出样例 JSONL。生产版:增加后端 export_labels(filters, format) 并写入用户选择路径。</p>
    <button @click="download">下载样例 JSONL</button>
  </section>
</template>

<script setup lang="ts">
function download() {
  const text = [
    JSON.stringify({ message_id: "m1", industry: "金融", type: "交易提醒" }),
    JSON.stringify({ message_id: "m2", industry: "通用", type: "验证码" }),
  ].join("\n");
  const blob = new Blob([text], { type: "application/jsonl" });
  const a = document.createElement("a");
  a.href = URL.createObjectURL(blob);
  a.download = "labels.sample.jsonl";
  a.click();
  URL.revokeObjectURL(a.href);
}
</script>

前端依赖:package.json(最小可跑)

{
  "name": "sms-tagging-officer",
  "private": true,
  "packageManager": "pnpm@9.0.0",
  "scripts": {
    "dev": "pnpm -C apps/desktop dev",
    "build": "pnpm -C apps/desktop build",
    "tauri:dev": "pnpm -C apps/desktop tauri dev",
    "tauri:build": "pnpm -C apps/desktop tauri build"
  },
  "devDependencies": {
    "pnpm": "^9.0.0"
  }
}
apps/desktop/package.json
{
  "name": "sms-tagging-officer-desktop",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "tauri": "tauri"
  },
  "dependencies": {
    "@tauri-apps/api": "^1.5.0",
    "pinia": "^2.1.7",
    "vue": "^3.4.0",
    "vue-router": "^4.2.5",
    "papaparse": "^5.4.1",
    "xlsx": "^0.18.5"
  },
  "devDependencies": {
    "@tauri-apps/cli": "^1.5.0",
    "@vitejs/plugin-vue": "^5.0.0",
    "typescript": "^5.3.3",
    "vite": "^5.0.0"
  }
}
apps/desktop/src/utils/sample.ts
export function buildSampleRows() {
  return [
    { id:"m1", received_at:"2026-02-10 10:01:00", sender:"中国银行", phone:"95566", source:"sample", content:"【中国银行】您尾号1234卡于2026-02-10 09:58消费58.20元,余额1020.55元。" },
    { id:"m2", received_at:"2026-02-10 10:02:00", sender:"支付宝", phone:"95188", source:"sample", content:"【支付宝】验证码 493821,用于登录验证,5分钟内有效。" },
    { id:"m3", received_at:"2026-02-10 10:03:00", sender:"顺丰速运", phone:"95338", source:"sample", content:"【顺丰】快件已到达XX驿站,取件码 662913,请于18:00前取走。" },
    { id:"m4", received_at:"2026-02-10 10:04:00", sender:"12345", phone:"12345", source:"sample", content:"【12345政务】您反映的问题已受理,查询进度请访问 https://gov.example.cn/track" },
    { id:"m5", received_at:"2026-02-10 10:05:00", sender:"某运营商", phone:"10086", source:"sample", content:"您本月话费账单已出,应缴 89.50 元,逾期将影响服务。" },
    { id:"m6", received_at:"2026-02-10 10:06:00", sender:"平安保险", phone:"95511", source:"sample", content:"【平安】您的保单将于2026-03-01到期,请及时续保,详询4008000000。" },
    { id:"m7", received_at:"2026-02-10 10:07:00", sender:"某电商", phone:"1069xxxx", source:"sample", content:"【京东】会员账号绑定手机号变更成功,如非本人操作请致电950618。" },
    { id:"m8", received_at:"2026-02-10 10:08:00", sender:"某平台", phone:"1069xxxx", source:"sample", content:"【美团】本店新客立减券已到账,点击 http://promo.example.com 立即使用。" },
    { id:"m9", received_at:"2026-02-10 10:09:00", sender:"公安反诈", phone:"12110", source:"sample", content:"【反诈中心】警惕冒充客服退款诈骗,任何验证码均不要透露。" },
    { id:"m10", received_at:"2026-02-10 10:10:00", sender:"未知", phone:"unknown", source:"sample", content:"您有一笔订单待处理,请联系 13800138000 获取详情。" }
  ];
}

9) README:运行、打包、离线分发、自测与验收点

README.md

# 短信智标官(SMS Tagging Officer)

离线桌面软件:对几千条短信进行“行业大类 + 类型”两层标签、实体抽取与可解释 reasons 输出。
推理完全离线:llama.cpp + GGUF 模型文件(用户在设置页选择路径)。
数据落地:SQLite(messages / labels / audit_logs),支持导入、批处理、复核、导出。

## 1. 功能边界(固定枚举)
一级行业:金融、通用、政务、渠道、互联网、其他  
二级类型:验证码、交易提醒、账单催缴、保险续保、物流取件、会员账号变更、政务通知、风险提示、营销推广、其他  
实体字段:brand、verification_code、amount、balance、account_suffix、time_text、url、phone_in_text(缺失填 null

每条输出稳定 JSON,必须包含:
confidence、reasons、rules_version、model_version、schema_version、needs_review

## 2. 本地推理集成方式
默认 Provider:llama.cpp sidecar(可执行文件随应用打包/或由用户指定路径)
后续可扩展 Provider:比如其他本地推理、甚至远端(如果你未来允许联网)

Provider 抽象:classify(payload) -> ModelOutput

## 3. 环境准备(开发)
- Node.js 18+
- pnpm 9+
- Rust stable
- Tauri CLI

```bash
pnpm i
pnpm tauri:dev


## 4. llama.cpp 与模型文件准备(运行期离线)

你需要准备:

1.  llama.cpp 可执行文件:llama-cli (Windows: llama-cli.exe)  llama
1.  GGUF 模型文件(推荐小模型 q4/q5 量化)

把二进制放在本机任意目录,模型放在本机任意目录。  
在应用「设置页」填写路径后,批处理页即可运行。

## 5. 一键自测步骤(验收闭环)

1.  打开应用 -> 导入页 -> 点击「加载内置样例」-> 导入(10条)

1.  设置页:填写 sidecar_path  model_path

1.  批处理页:勾选「只跑未标注」-> 开始

1.  列表复核页:输入 m1/m2... 打开抽屉,修改字段 -> 保存

1.  验证点:

    -   labels 表存在 message_id 对应记录
    -   audit_logs 表新增一条记录(before/after 有差异)
    -   输出 JSON 字段齐全(entities 所有字段都有,缺失为 null
    -   冲突样本进入 needs_review(例如包含金额/链接却被模型判“其他”时)

## 6. 如何验证输出 JSON 约束

仓库 assets/json_schema_output.json 提供 JSON Schema。  
你可以用任意 JSON Schema 校验工具对导出的 JSONL 逐行验证。

## 7. 打包与分发

```
pnpm tauri:build
```

产物在:

-   Windows: src-tauri/target/release/bundle/msi  nsis
-   macOS: .app / dmg
-   Linux: AppImage / deb

### 分发注意

-   运行期完全离线:SQLite 文件在应用数据目录,模型与 sidecar 在本机路径。

-   若希望随包附带 llama.cpp 二进制:

    -    sidecar 放入 src-tauri/bin/ 并在 tauri.conf.json  sidecar
    -   设置页默认填入 sidecar  resolved 路径(扩展点)

## 8. 扩展点(生产完善)

-   列表页接入分页查询与过滤:按 industry/type/needs_review/confidence 区间/含链接/含验证码/含金额等
-   导出接口:按筛选条件导出 CSV/JSONL,支持仅导出已复核或全量
-   health check:在设置页调用后端 command 执行一次 `llama -m ... -p "ping"` 并校验 JSON
-   模型输出更严格:加入 JSON schema 约束解码(可在 llama.cpp 参数或 prompt 中增强)

# ````

---

## 你要的“评价”结论(基于可交付性)

- 这套框架在离线桌面交付里属于 **能稳定跑通、能验收、能扩展** 的形态:规则层先把高确定性短信打穿,模型层只收灰区,融合层把冲突聚焦到 needs_review 队列,复核动作全审计,最终输出稳定 JSON,满足报表与治理的回放要求。
- 关键风险点集中在两处:**模型输出稳定性**(严格 JSON/枚举约束)与 **批处理吞吐**(并发、超时、重试、不卡 UI)。这版工程把两者都“工程化地钉住”了:sidecar 调用 + timeout + semaphore + 事件推送;prompt 强约束 + 输出截取 + 失败落盘。
- 后续要上生产强度,你主要补三块:列表查询/过滤与导出接口、设置页 health check、规则字典与企业自定义(品牌/机构词典、金融/政务更细的 signals),这些都已经留好了扩展点。

---

如果你希望我把“列表页的分页查询 + 过滤条件(行业/类型/needs_review/置信度区间/含链接/含验证码/含金额)+ 导出 CSV/JSONL(仅复核/全量)”也补成完整可跑代码,我可以在现有骨架上继续把 `query_messages(filters)`、`export_labels(filters, format, reviewed_only)` 两个后端 command 和前端 FilterBar + MessageTable 的整套链路补齐。
```

看一下回调

作者 ccnocare
2026年2月10日 16:03

回调函数的本质是:把一个函数作为参数传递给另一个函数,在合适的时机(比如异步操作完成、某个事件触发)由后者调用这个函数。前端中回调的实现方式主要分为基础回调、高阶用法和现代语法糖(Promise/async-await本质也是基于回调的封装)。


一、回调函数的核心本质(先理解基础)

回调函数的核心逻辑是「委托执行」:

// 核心结构:函数A接收函数B作为参数,在A内部调用B 
function 主函数(回调函数) { 
    // 执行主逻辑(同步/异步) 
    回调函数(/* 传递参数 */); // 在合适时机调用回调 
} 
// 使用:传入回调函数 
主函数(function 回调函数(参数) { 
    // 回调逻辑 
}); 

二、回调的具体实现方式(按场景分类)

1. 同步回调(立即执行)

场景:数组方法(forEach/map/filter)、简单的逻辑委托(如数据处理完成后回调)。 特点:回调函数在主函数执行过程中立即同步执行,无延迟。

示例1:自定义同步回调函数

// 主函数:处理数据,完成后调用回调返回结果 
function processData(data, callback) { 
    // 同步处理数据(比如过滤、转换) 
    const processed = data.map(item => item * 2); 
    // 调用回调,传递处理后的结果 
    callback(processed); 
} 
// 使用:传入回调函数 
const rawData = [1, 2, 3]; 
processData(rawData, function(result) { 
    console.log('处理后的数据:', result); // 输出:[2,4,6] 
}); 
// 箭头函数简化回调(更常用) 
processData(rawData, (result) => { 
    console.log('箭头函数回调:', result); // 输出:[2,4,6] 
}); 

示例2:数组内置同步回调(高频使用)

const arr = [1, 2, 3, 4]; 

// forEach 回调:遍历每个元素 
arr.forEach((item, index) => { 
    console.log(`索引${index}的值:${item}`); 
}); 

// filter 回调:过滤符合条件的元素 
const evenArr = arr.filter((item) => item % 2 === 0);
console.log(evenArr); // [2,4] 

// map 回调:转换元素 
const doubleArr = arr.map((item) => item * 2); 
console.log(doubleArr); // [2,4,6,8] 

2. 异步回调(延迟执行)

场景:定时器、事件监听、AJAX请求、文件读写(Node.js)等异步操作。 特点:回调函数不会立即执行,而是等待异步操作完成后触发,这是回调最核心的使用场景。

示例1:定时器回调(setTimeout/setInterval)

// setTimeout:延迟1秒执行回调 
setTimeout(() => { 
    console.log('1秒后执行的回调'); 
}, 1000); 

// 带参数的异步回调 
function delayCallback(time, callback) { 
    setTimeout(() => {
        callback(`延迟${time}ms后执行`); 
    }, time); 
} 

// 使用 
delayCallback(1500, (msg) => { 
    console.log(msg); // 1500ms后输出:延迟1500ms后执行 
}); 

示例2:DOM事件监听回调(前端最常用)

<button id="btn">
    点击我
</button> 
<script> 
    const btn = document.getElementById('btn'); 
    
    // 方式1:匿名函数回调 
    btn.addEventListener('click', () => { 
        console.log('按钮被点击(匿名回调)'); 
    }); 
    
    // 方式2:命名函数回调(便于移除监听)
    function handleClick() { 
        console.log('按钮被点击(命名回调)'); 
    }
    
    btn.addEventListener('click', handleClick); 
    
    // 移除回调(必须用命名函数) 
    // btn.removeEventListener('click', handleClick); 
</script> 

示例3:AJAX请求回调(经典异步场景)

// 原生XMLHttpRequest回调 
function requestData(url, successCallback, errorCallback) { 
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url);
    xhr.onload = function() {
        if (xhr.status >= 200 && xhr.status < 300) { 
            // 成功回调:传递响应数据
            successCallback(JSON.parse(xhr.responseText));
        } else { 
            // 失败回调:传递错误信息 
            errorCallback(new Error(`请求失败:${xhr.status}`)); 
        } 
    }; 
    xhr.onerror = function() { 
        errorCallback(new Error('网络错误')); 
    }; 
    xhr.send(); 
} 
// 使用:传入成功/失败回调 
requestData( 
    'https://jsonplaceholder.typicode.com/todos/1', 
    (data) => { 
        console.log('请求成功:', data); // 输出返回的todo数据 
    },
    (error) => { 
        console.error('请求失败:', error); 
    } 
); 

3. 回调的进阶用法

(1)错误优先回调(Node.js规范,前端也常用)

核心规则:回调函数的第一个参数固定为错误对象(无错误则为null),第二个及以后参数为成功数据。 优势:统一错误处理逻辑,可读性更高。

// 模拟读取文件(Node.js风格) 
function readFile(filename, callback) { 
    // 模拟异步读取 
    setTimeout(() => { 
        if (filename === 'test.txt') { 
            // 无错误:第一个参数为null,第二个为数据 
            callback(null, '文件内容:Hello World'); 
        } else { 
            // 有错误:第一个参数为错误对象 
            callback(new Error('文件不存在'), null); 
        } 
    }, 1000); 
} 
// 使用:错误优先回调 
readFile('test.txt', (err, data) => { 
    if (err) { 
        // 优先处理错误 
        console.error('读取失败:', err); 
        return; 
    } 
    // 处理成功数据 
    console.log('读取成功:', data);
});
readFile('none.txt', (err, data) => { 
    if (err) { 
        console.error('读取失败:', err); // 输出:读取失败:Error: 文件不存在 
        return; 
    } 
    console.log(data); 
}); 

(2)回调地狱(问题场景)与解决

回调地狱:多层异步回调嵌套,导致代码可读性差、维护困难。

// 回调地狱示例:多层嵌套 
setTimeout(() => { 
    console.log('第一步:获取用户ID'); 
    const userId = 1; 
    setTimeout(() => { 
        console.log(`第二步:根据ID${userId}获取用户信息`); 
        const user = { id: 1, name: '张三' }; 
        setTimeout(() => { 
            console.log(`第三步:获取${user.name}的订单`); 
            const orders = [{ id: 101, goods: '手机' }];
            console.log('最终结果:', orders); 
        }, 1000); 
    }, 1000); 
}, 1000);

解决方式1:拆分命名函数

// 拆分回调为命名函数,减少嵌套 
function getUserId(callback) { 
    setTimeout(() => { 
        console.log('第一步:获取用户ID'); 
        callback(1); 
    }, 1000); 
} 
function getUserInfo(userId, callback) { 
    setTimeout(() => { 
        console.log(`第二步:根据ID${userId}获取用户信息`); 
        callback({ id: userId, name: '张三' }); 
    }, 1000); 
}
function getOrders(user, callback) { 
    setTimeout(() => { 
        console.log(`第三步:获取${user.name}的订单`); 
        callback([{ id: 101, goods: '手机' }]); 
    }, 1000); 
} 
// 链式调用(无嵌套) 
getUserId((userId) => { 
    getUserInfo(userId, (user) => { 
        getOrders(user, (orders) => { 
            console.log('最终结果:', orders); 
        }); 
    });
});

解决方式2:Promise封装(现代主流) Promise本质是回调的「优雅封装」,将嵌套回调转为链式调用:

// 用Promise封装异步操作 
function getUserId() { 
    return new Promise((resolve) => { 
        setTimeout(() => { 
            console.log('第一步:获取用户ID'); 
            resolve(1);
        }, 1000); 
    }); 
} 
function getUserInfo(userId) {
    return new Promise((resolve) => {
        setTimeout(() => { 
            console.log(`第二步:根据ID${userId}获取用户信息`);
            resolve({ id: userId, name: '张三' }); 
        }, 1000); 
    }); 
}
function getOrders(user) { 
    return new Promise((resolve) => { 
        setTimeout(() => {
            console.log(`第三步:获取${user.name}的订单`);
            resolve([{ id: 101, goods: '手机' }]); 
        }, 1000); 
    });
} 
// 链式调用(无嵌套) 
getUserId()
    .then((userId) => getUserInfo(userId))
    .then((user) => getOrders(user))
    .then((orders) => {
        console.log('最终结果:', orders);
    });

解决方式3:async/await(语法糖,最简洁) async-await是Promise的语法糖,彻底消除回调写法,转为同步风格:

// 基于上面的Promise函数,使用
async-await async function fetchData() { 
    const userId = await getUserId(); 
    const user = await getUserInfo(userId); 
    const orders = await getOrders(user); 
    console.log('最终结果:', orders); 
} 
fetchData();

4. 自定义可控回调(带条件/上下文)

示例1:带执行条件的回调

// 只有满足条件才执行回调 
function checkPermission(role, callback) { 
    const adminRoles = ['admin', 'super_admin'];
    if (adminRoles.includes(role)) { 
        callback(null, '权限通过'); 
    } else { 
        callback(new Error('无权限'), null); 
    } 
}
// 使用 
checkPermission('admin', (err, msg) => { 
    if (err) { 
        console.error(err); return; 
    } 
    console.log(msg); // 输出:权限通过 
});
checkPermission('user', (err, msg) => { 
    console.error(err); // 输出:Error: 无权限 
});

示例2:绑定this上下文的回调

const obj = { 
    name: '测试对象', 
    execute(callback) { 
        // 方式1:用call/apply绑定this 
        callback.call(this);

        // 方式2:用bind绑定(返回新函数) 
        // const boundCallback = callback.bind(this); 
        // boundCallback(); 
    } 
}; 
// 回调函数需要访问obj的this 
obj.execute(function() { 
    console.log(this.name); // 输出:测试对象(若不绑定则为undefined) 
}); 

总结

前端回调函数的核心知识点和实现方式:

  1. 核心本质:函数作为参数传递,在主函数的指定时机执行,分为同步/异步两类。
  2. 基础实现
  • 同步回调:数组方法(forEach/map)、自定义同步逻辑委托;
  • 异步回调:定时器、事件监听、AJAX请求(前端核心场景)。
  1. 进阶用法
  • 错误优先回调:第一个参数为错误对象(Node.js规范,前端也常用);
  • 回调地狱:多层嵌套导致可读性差,可通过拆分命名函数、Promise、async-await解决。
  1. 关键细节:回调中this上下文需要手动绑定(call/apply/bind),否则会丢失。 回调是前端异步编程的基础,Promise和async-await都是对回调的封装和优化,理解回调的本质能帮你更好地掌握现代异步语法。

一文吃透 Angular Module & Standalone

2026年2月10日 15:59

本文由体验技术团队张婷原创。

一、核心概念:两种架构的本质区别

无论是 Module 还是 Standalone,核心目标都是解决 Angular 应用中组件、指令、管道、服务的组织、依赖管理与复用问题,只是实现方式截然不同。

1. 传统架构:NgModule 模块机制

NgModule 是 Angular 原生的模块化方案,本质是一个“功能容器”,通过装饰器 @NgModule 定义,承担着“声明、导入、导出、提供”四大核心职责,将分散的功能聚合为一个可管理的单元。

其核心逻辑是“模块中心化”——所有组件必须归属某个模块,依赖通过模块统一导入,服务通过模块提供作用域,这种设计非常适合大型项目的分层与分工。

1.png

2. 革新方案:Standalone 独立组件

Standalone 是 Angular 为简化开发推出的轻量化方案,通过在组件装饰器中设置 standalone: true,让组件摆脱对 NgModule 的依赖,实现“组件自包含”。 其核心逻辑是“组件中心化”——组件自身可直接导入所需的模块、其他独立组件,无需在模块中声明,大幅精简了模板代码,降低了入门门槛。

2.png

二、实操对比:代码层面的直观差异

理论不如实操,我们通过一个简单的“根组件+头部组件”场景,对比两种模式的实现代码,感受其差异。

1. NgModule 实现方式

需创建模块文件(如 app.module.ts),集中管理组件、依赖和服务,步骤相对繁琐: HeaderComponent 需单独创建 header.component.ts 文件,模板内容需完整定义,同时模块中必须声明所有用到的组件,否则会报“组件未注册”错误。

// header.component.ts(传统组件,需在模块中声明)
import { Component } from '@angular/core';

@Component({
  selector: 'app-header',
  template: `
    系统头部
  `,
  styles: [`
    .header { padding: 16px; background: #f5f5f5; border-bottom: 1px solid #eee; }
    nav { margin-top: 8px; color: #666; }
  `]
})
export class HeaderComponent { }

// app.component.ts(根组件)
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <app-header></app-header>
    Angular Module 模式示例
  `
})
export class AppComponent {
  showContent = true; // 控制内容显示,演示*ngIf指令用法
}

// app.module.ts(核心模块文件)
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { CommonModule } from '@angular/common'; // 提供*ngIf、*ngFor等基础指令

import { AppComponent } from './app.component';
import { HeaderComponent } from './header/header.component';

@NgModule({
  declarations: [
    // 声明模块内的组件、指令、管道(必须在此注册,否则无法使用)
    AppComponent,
    HeaderComponent
  ],
  imports: [
    // 导入依赖模块:BrowserModule用于浏览器渲染,CommonModule提供基础指令
    BrowserModule,
    CommonModule
  ],
  providers: [
    // 提供模块级服务(模块内所有组件共享同一个实例)
    { provide: 'API_BASE_URL', useValue: 'https://api.example.com' }
  ],
  bootstrap: [AppComponent] // 指定根组件,Angular启动时会渲染该组件
})
export class AppModule { }

// main.ts(应用启动入口文件)
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app.module';

// 通过编译模块启动应用,这是传统Module模式的标准启动方式
platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error('应用启动失败:', err));

2.Standalone 实现方式

无需模块文件,组件自身声明依赖,启动流程更简洁。

补充说明:独立组件可直接导入其他独立组件,无需额外声明;依赖导入遵循“按需导入”原则,仅导入当前组件所需模块,减少冗余。


// header.component.ts(独立头部组件,无需模块声明)
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; // 自身导入所需模块

@Component({
  selector: 'app-header',
  standalone: true, // 标记为独立组件,摆脱模块依赖
  imports: [CommonModule], // 导入基础指令模块,用于后续可能的*ngIf等用法
  template: `
    独立组件头部<nav *首页 | 关于我们 | 联系我们
  `,
  styles: [`
    .header { padding: 16px; background: #e8f4f8; border-bottom: 1px solid #d1e7dd; }
    nav { margin-top: 8px; color: #333; }
  `]
})
export class HeaderComponent {
  showNav = true; // 组件内部状态,控制导航显示
}

// app.component.ts(独立根组件)
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HeaderComponent } from './header/header.component'; // 直接导入独立组件

// 抽离共享依赖(缓解重复导入问题,大型项目推荐用法)
const SharedDependencies = [CommonModule, HeaderComponent];

@Component({
  selector: 'app-root',
  standalone: true, // 核心标记:独立组件
  imports: [SharedDependencies], // 导入所需依赖(模块+独立组件)
  providers: [
    // 组件级服务:默认当前组件及子组件共享实例,若需全局单例可加providedIn: 'root'
    { provide: 'API_BASE_URL', useValue: 'https://api.example.com', providedIn: 'root' }
  ],
  template: `
<app-header></app-header>
    Angular Standalone 模式示例
  `
})
export class AppComponent {
  showContent = true;

  // 交互方法,演示组件基础功能
  toggleContent() {
    this.showContent = !this.showContent;
  }
}

// main.ts(独立组件启动入口)
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app.component';

// 直接启动独立根组件,无需模块介入,启动流程更简洁
bootstrapApplication(AppComponent, {
  // 可选:全局配置,如提供全局服务(替代模块级providers)
  providers: [{ provide: 'GLOBAL_CONFIG', useValue: { env: 'production' } }]
})
  .catch(err => console.error('应用启动失败:', err));

三、传统与革新,孰优孰劣?

两种方案各有优劣,没有绝对的“完美”。

1. NgModule 的优缺点

优点:

  • 成熟稳定,生态兼容:作为 Angular 核心机制,兼容所有第三方库、插件和传统项目,几乎无适配风险,是老项目维护的首选。

  • 强模块化封装:适合大型团队协作,可按业务域(如用户模块、订单模块)拆分独立 NgModule,边界清晰,便于分工维护和权限管控。

  • 集中式依赖管理:模块级统一导入依赖,避免多个组件重复导入相同模块,减少冗余代码,适合大量组件共享依赖的场景。

  • 服务作用域清晰:模块级服务默认在模块内单例,无需额外配置即可实现“模块内共享、模块间隔离”,适合按模块隔离业务逻辑的场景。

缺点:

  • 模块代码冗余:即使是简单组件,也需创建模块文件,编写 @NgModule 装饰器及 declarations/imports 等配置,增加无业务价值的模块代码。

  • 学习成本:新手易混淆 declarations(声明组件)、imports(导入模块)、exports(导出组件)的用法,常出现“组件找不到”“指令未注册”等错误。

  • 编译效率略低:模块是编译基本单元,修改一个组件可能触发整个模块的重新编译,大型模块会增加编译耗时。

  • 组件复用成本高:组件必须绑定模块,跨项目复用单个组件时,需连带其所属模块一起复制,灵活性不足。

2. Standalone 的优缺点

优点:

  • 轻量化,开发效率高:无需创建模块文件,入门门槛低,中小型项目、原型开发速度大幅提升。

  • 精准依赖,代码精简:组件仅按需导入自身所需依赖,避免模块级导入带来的冗余依赖,代码更清晰、可维护性更强。

  • 编译性能更优:独立组件是最小编译单元,修改单个组件仅触发自身重新编译,大型项目编译速度提升明显。

  • 复用性强:组件完全独立于模块,跨项目复用只需复制组件文件,无需连带模块,是组件库开发的最优选择。

缺点:

  • 依赖重复导入:多个独立组件需同一模块(如 CommonModule)时,需各自导入,易出现重复代码(可通过抽离共享组件模块导入缓解,如下图)。

3.png

  • 部分老库适配不足:少数未升级的第三方库依赖模块级特性,需额外适配才能在独立组件中使用。

  • 服务作用域配置复杂默认是组件级单例,若需实现全局单例或模块级单例,需额外配置 providedIn: 'root' 或通过共享组件封装,比 NgModule 繁琐。

PS

  • 组件级单例:假如你有 3 个独立的 ButtonComponent,都注入了同一个 CountService,那么这 3 个组件会各有一个 CountService,点击按钮计数时,各自的数字不会互相影响。

  • 全局级单例:全局只有一个服务实例”(比如用户登录状态、全局缓存)。

四、各有优劣,如何选择?

NgModule 代表了 Angular 传统的“强模块化”设计理念,Standalone 则是 Angular 对“轻量化、高效化”的探索,两者并非非此即彼的替代关系,而可以是互补关系。

  • 全新中小型项目/原型开发:优先选择 Standalone 组件。轻量化特性可快速迭代,减少模板代码,降低团队协作成本。

  • 大型企业级项目/多人协作:采用混合模式。保留核心业务模块(NgModule)的封装性,新开发的组件、指令使用 Standalone 模式,逐步迁移老组件,兼顾稳定性和开发效率。

总结

作为开发者,我们无需纠结于“哪种更好”,而是要理解两种方案的设计初衷,根据项目规模、团队结构、复用需求灵活选型。在实际开发中,可以选择混合使用两种模式,既能保留传统架构的稳定性,又能享受新范式的高效性。

一点拙见分享,抛砖引玉,欢迎大家与我交流补充,共同进步~

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue源码:github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyVue、TinyEngine、TinyPro、TinyNG、TinyCLI、TinyEditor
如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

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

2026年2月10日 09:59

前端开发这几年,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 性能优化实战

2026年2月10日 09:54

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节点]原理解析与实际应用

作者 SmalBox
2026年2月10日 09:54

【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 ! 我再也不用切账号了

作者 兆子龙
2026年2月10日 09:53

告别多账号切换烦恼: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]:核心原则与决策方法论

2026年2月10日 09:39

写在前面

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

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

架构师的价值,不在于他知道多少个 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) 的设计陷阱与最佳实践》**,帮你避开那些昂贵的学费。

❌
❌