阅读视图

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

深入理解 Vue keep-alive:缓存本质、触发条件与生命周期对比

一、先明确核心结论

keep-alive 是 Vue 内置的抽象组件(不渲染真实 DOM),它的核心作用是缓存被包裹的组件实例,缓存的关键数据结构如下:

  • 缓存容器:keep-alive 实例上的 this.cache(一个对象,key 是组件的「缓存标识」,value 是组件实例);
  • 辅助记录:this.keys(一个数组,存储缓存组件的 key,用于实现 max 缓存数量限制);
  • 挂载关系:被缓存的组件实例 → 作为 this.cache 对象的属性值 → 挂在 keep-alive 组件实例上,而非被缓存组件自己的实例上。

二、keep-alive 挂载缓存的完整过程(分步骤拆解)

以 Vue 2 为例(Vue 3 逻辑一致,仅源码实现细节略有差异),核心流程如下:

步骤 1:keep-alive 初始化,创建缓存容器

keep-alive 组件初始化时,会在自身实例上创建两个核心属性,用于存储缓存:

// keep-alive 组件的初始化逻辑(简化版)
export default {
  name: 'keep-alive',
  abstract: true, // 抽象组件,不参与DOM渲染
  props: {
    include: [String, RegExp, Array], // 需缓存的组件
    exclude: [String, RegExp, Array], // 排除缓存的组件
    max: [String, Number] // 最大缓存数量
  },
  created() {
    this.cache = Object.create(null); // 缓存容器:{ key: 组件实例 }
    this.keys = []; // 缓存key列表:[key1, key2...]
  },
  // ...其他生命周期
}
  • this.cache :空对象,后续用来存「缓存标识 → 组件实例」的映射;
  • this.keys :空数组,记录缓存 key 的顺序,用于 LRU 淘汰(超出 max 时删除最久未使用的缓存)。

步骤 2:组件首次渲染,判断是否缓存

当 keep-alive 包裹的组件首次渲染时,keep-alive 的 render 函数会执行核心逻辑:

  1. 获取被包裹组件的**「缓存标识」**(key):
    • 默认 key:组件名 + 组件实例的uid(避免同组件不同实例冲突);
    • 自定义 key:可通过 key 属性指定(如 <keep-alive><component :is="comp" :key="compKey" /></keep-alive>)。
  1. 判断是否符合缓存规则(include / exclude):
    • 若符合:将组件实例存入 this.cache,并把 key 加入 this.keys
    • 若不符合:不缓存,直接渲染组件(和普通组件一样)。

举个例子:

<keep-alive>
      <router-view v-if="$route.meta.keepAlive" />
</keep-alive>

步骤 3:缓存组件实例,挂载到 keep-alive 上

核心逻辑简化如下:

// keep-alive 的 render 函数核心逻辑(简化版)
render() {
  const slot = this.$slots.default;
  const vnode = getFirstComponentChild(slot); // 获取被包裹的第一个组件vnode
  const componentOptions = vnode && vnode.componentOptions;
  
  if (componentOptions) {
    // 1. 生成缓存key(核心:唯一标识组件实例)
    const key = this.getCacheKey(vnode);
    const { cache, keys } = this;

    // 2. 判断是否需要缓存(符合include,不符合exclude)
    if (this.shouldCache(componentOptions)) {
      // 3. 若缓存中已有该组件实例,直接复用
      if (cache[key]) {
        vnode.componentInstance = cache[key].componentInstance;
        // 更新key顺序(LRU:把当前key移到最后,标记为最近使用)
        remove(keys, key);
        keys.push(key);
      } else {
        // 4. 首次渲染:将组件vnode(包含实例)存入缓存
        cache[key] = vnode;
        keys.push(key);
        // 5. 超出max时,删除最久未使用的缓存
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode);
        }
      }
      // 标记组件为“被缓存”,避免重复初始化
      vnode.data.keepAlive = true;
    }
  }
  return vnode;
}

关键挂载动作cache[key] = vnode → 组件的 vnode(包含 componentInstance 即组件实例)被作为 cache 对象的属性值,挂载到 keep-alive 实例的 this.cache 上。

步骤 4:组件再次渲染,复用缓存实例

当被缓存的组件需要再次渲染时(比如路由切换后返回):

  1. keep-alive this.cache 中根据 key 取出对应的组件实例;
  2. 将缓存的实例赋值给新的 vnode 的 componentInstance
  3. 直接复用该实例渲染,不再执行组件的 created / mounted 等生命周期(而是触发 activated 钩子)。

三、关键细节:为什么缓存不挂在被缓存组件自己的实例上?

  1. 逻辑合理性keep-alive 是「缓存管理者」,应该由管理者统一存储和管理所有被缓存的组件,而非让组件自己存储;
  2. 避免内存泄漏:若缓存挂在被缓存组件实例上,组件实例本身无法被销毁(因为缓存引用了它),而 keep-alive 统一管理可通过 maxexclude 主动清理缓存;
  3. 多实例隔离:多个 keep-alive 组件的缓存是隔离的(比如页面 A 和页面 B 各有一个 keep-alive),每个 keep-alive 实例有自己的 cache,不会互相干扰。

总结:

一、先肯定你的正确认知

  1. ✅ keep-alive 是 Vue 官方内置的抽象组件(不渲染真实 DOM),被包裹的组件实例 / 状态会挂载在keep-alive 实例的 cache 对象上(而非组件自身);
  2. ✅ 缓存的核心内容是组件的 VNode(包含组件实例、DOM 节点描述、数据状态如 data/props/ 输入框值等);
  3. ✅ 组件的缓存触发和「组件失活」强相关(比如路由跳转导致组件被隐藏)。

二、需要修正 / 补充的关键细节

细节 1:“组件经过 keep-alive 时被缓存” → 不是 “经过”,而是 “组件被 keep-alive 包裹且失活时才缓存”

keep-alive 不会 “主动拦截” 组件,而是当被包裹的组件从「激活状态」变为「失活状态」时,才会将其 VNode 存入缓存(而非加载时就缓存)。

  • 激活状态:组件在页面中可见(比如当前路由匹配的 Home 组件);
  • 失活状态:组件被隐藏(比如跳转到 List 路由,Home 组件被 router-view 卸载)。

简单说:keep-alive 是 “挽留” 即将被销毁的组件 —— 默认情况下,组件失活会被销毁,而 keep-alive会把它存入缓存,避免销毁。

细节 2:“只有页面跳转才缓存” → 不完全对,「组件失活」都触发缓存(不止路由跳转)

路由跳转是最常见的 “组件失活” 场景,但不是唯一场景:

  • 场景 1(路由跳转):/home → /list,Home 组件失活 → 被缓存;
  • 场景 2(条件渲染):<keep-alive><component :is="compName" /></keep-alive>,当compName 从 Home 改为 List 时,Home 失活 → 被缓存;
  • 场景 3(v-if 隐藏):<keep-alive><div v-if="show">Home组件</div></keep-alive>,当show 从 true 改为 false 时,Home 失活 → 被缓存。

核心:只要 keep-alive 包裹的组件从 “渲染在页面上” 变为 “不渲染”,且符合 include/exclude 规则,就会被缓存。

细节 3:“只加载页面不跳转,不会缓存” → 准确说:“组件未失活,缓存容器中已有该组件的 VNode,但未触发「缓存复用」”

即使不跳转,只要组件被 keep-alive 包裹并完成首次渲染:

  1. keep-alive cache 已经存入了该组件的 VNode (可以通过前面的代码查到);
  2. 只是因为组件未失活,所以不会触发 activated 钩子,也不会体现出 “缓存效果”(比如输入框输入内容,不跳转的话,内容本来就在,看不出缓存);
  3. 只有当组件失活后再次激活(比如跳转回来),才会从缓存中复用 VNode,此时能看到 “状态保留”(比如输入框内容还在)—— 这才是缓存的 “可见效果”。

举个直观例子:

  • 步骤 1:访问 /home,Home 组件渲染(激活),keep-alive.cache 中已有 Home 的 VNode(但未体现缓存);
  • 步骤 2:在 Home 输入框输入 “123”,跳转到 /list(Home 失活),keep-alive 保留 Home 的 VNode(包含输入框的 “123”);
  • 步骤 3:跳回 /home(Home 激活),keep-alive 复用缓存的 VNode,输入框仍显示 “123”—— 这就是缓存的效果。

如果只停留在步骤 1(不跳转),虽然缓存容器中有 Home 的 VNode,但因为没有 “失活→激活” 的过程,所以看不到缓存的效果,并非 “没有缓存”。

细节 4:缓存的 VNode 包含什么?→ 不止节点 / 属性,还有组件的「完整实例状态」

VNode 是组件的 “虚拟描述”,缓存 VNode 本质是缓存组件实例:

  • 包含 DOM 结构描述(比如 <div class="home">);
  • 包含组件的响应式数据data/computed/props);
  • 包含组件的 DOM 状态(输入框值、滚动条位置、复选框勾选状态);
  • 包含组件的生命周期状态不会再执行 created / mounted ,而是执行 activated)。

三、总结:精准理解 keep-alive 的缓存逻辑

  1. 挂载关系keep-alive 是 “缓存管理者”,被包裹组件的 VNode(含实例 / 状态)挂载在 keep-alive实例的 cache 对象上;
  2. 缓存触发:组件被 keep-alive 包裹 + 组件从「激活→失活」(路由跳转 / 条件隐藏等)→ 存入缓存;
  3. 缓存复用:组件从「失活→激活」→ 从 cache 中取出 VNode 复用(不重新创建实例,保留状态);
  4. 可见效果:只有 “失活→激活” 的过程,才能体现缓存(状态保留),仅加载组件不跳转,缓存存在但无 “可见效果”。

简单记: keep-alive 的核心是 “保活”—— 不让失活的组件销毁,而是存入缓存,下次激活时直接复用,避免重复创建 / 销毁,同时保留组件状态。


keep-alive组件加载生命周期对比

阶段 Vue 2 生命周期 Vue 3 组合式 API 核心特点
首次加载 beforeCreate → created → beforeMount → mounted → activated setup → onBeforeMount → onMounted → onActivated 完整生命周期,最后触发激活钩子
失活缓存 deactivated onDeactivated 仅触发失活钩子,不销毁组件
二次加载 activated onActivated 仅触发激活钩子,跳过创建 / 挂载
缓存销毁 deactivated → beforeDestroy → destroyed onDeactivated → onBeforeUnmount → onUnmounted 先失活,再销毁

uni-app 中配置 UnoCSS

本文档记录在 uni-app 项目中集成 UnoCSS 原子化 CSS 引擎的完整过程。

1. 安装依赖

pnpm add -D unocss @uni-helper/unocss-preset-uni

核心依赖说明:

  • unocss: UnoCSS 核心包
  • @uni-helper/unocss-preset-uni: 专为 uni-app 优化的预设,自动转换 rpx 单位,兼容小程序/App 平台

2. 配置 Vite 插件

vite.config.ts 中引入 UnoCSS 插件:

import { defineConfig } from 'vite';
import uni from '@dcloudio/vite-plugin-uni';

export default defineConfig(async () => {
  const UnoCSS = (await import('unocss/vite')).default; // 动态导入 UnoCSS 插件,兼容 HBuilderX
  return {
    plugins: [
      uni.default(),
      UnoCSS() // 添加 UnoCSS 插件
      // 其他插件...
    ]
  };
});

3. 创建 UnoCSS 配置文件

在项目根目录创建 uno.config.ts

import { presetUni } from '@uni-helper/unocss-preset-uni';
import { defineConfig, presetIcons, transformerDirectives, transformerVariantGroup } from 'unocss';

export default defineConfig({
  presets: [
    presetUni(), // uni-app 预设,必需
    presetIcons({
      scale: 1.2,
      warn: true,
      extraProperties: {
        display: 'inline-block',
        'vertical-align': 'middle'
      }
    })
  ],
  transformers: [
    transformerDirectives(), // 支持 @apply 等指令
    transformerVariantGroup() // 支持变体组简写,如 hover:(bg-gray-400 font-medium)
  ]
});

配置说明:

  • presetUni(): 自动处理 rpx/px 单位转换,支持小程序/App 平台特性
  • presetIcons(): 可选,支持图标集(需额外安装 @iconify-json/* 包)
  • transformerDirectives(): 支持 Tailwind 风格的 @apply 指令
  • transformerVariantGroup(): 简化变体书写,如 hover:(text-red bg-blue)

4. 引入 UnoCSS 样式

方式一:在 main.ts 中引入(推荐 CLI 编译)

import { createSSRApp } from 'vue';
import App from './App.vue';
import 'uno.css'; // 引入 UnoCSS 生成的样式

export function createApp() {
  const app = createSSRApp(App);
  return { app };
}

平台差异说明:

  • H5: 方式一
  • 小程序: 推荐方式一(main.ts 引入)
  • App(Android/iOS):
    • CLI 编译:方式一
    • HBuilderX 编译:方式二,将样式放在 App.vue 中以确保注入到 view 层

5. 使用 UnoCSS

配置完成后即可在模板中使用工具类:

<template>
  <view class="flex justify-center items-center h-screen">
    <view class="bg-blue-500 text-white p-4 rounded-lg"> Hello UnoCSS in uni-app! </view>
  </view>
</template>

6. 常见问题

6.1 样式在 App 端不生效

原因: uni-app 的 App 端分为 service 层和 view 层,main.ts 的引入只在 service 层生效。

解决方案:

  1. import 'uno.css' 移至 App.vue<style>
  2. 或使用内联样式/自定义组件包裹

6.2 HBuilderX 编译报错 "Cannot find module 'uno.css'"

原因: HBuilderX 不支持 virtual:uno.css 虚拟模块。

解决方案:

// 使用普通导入,不要使用 virtual: 前缀
import 'uno.css';

6.3 单位不是 rpx

原因: 未使用 presetUni() 预设。

解决方案: 确保 uno.config.ts 中包含:

presets: [presetUni()];

6.4 动态类名不生效

原因: UnoCSS 静态扫描无法识别动态拼接的类名。

错误示例:

<view :class="`text-${color}-500`"></view>

正确做法:

<view :class="color === 'red' ? 'text-red-500' : 'text-blue-500'"></view>

或使用 safelist 配置:

// uno.config.ts
export default defineConfig({
  safelist: ['text-red-500', 'text-blue-500']
  // ...
});

7. 开发体验优化

7.1 VSCode 插件

安装 UnoCSS 插件获得:

  • 类名自动补全
  • 类名悬停预览
  • 类名颜色高亮

7.2 UnoCSS Inspector

开发模式下访问 http://localhost:5173/__unocss 查看:

  • 当前生成的所有样式
  • 使用的工具类统计
  • 样式文件大小

7.3 配置别名

uno.config.ts 中自定义简写:

export default defineConfig({
  shortcuts: {
    btn: 'px-4 py-2 rounded bg-blue-500 text-white hover:bg-blue-600',
    card: 'bg-white rounded-lg shadow p-4'
  }
});

使用:

<view class="btn">按钮</view>
<view class="card">卡片</view>

8. 性能优势

相比传统 CSS 方案,UnoCSS 在 uni-app 中的优势:

  1. 按需生成:只生成使用到的样式,最终包体积更小
  2. 零运行时:编译时生成,无运行时开销
  3. 统一规范:团队成员使用相同工具类,样式一致性更好
  4. 跨平台兼容:自动处理不同平台的单位差异(rpx/px)

9. 参考资源

如何使用 vxe-table 导出为带图片的单元格到 excel 格式文件

如何使用 vxe-table 导出为带图片的单元格到 excel 格式文件 需要注意导出图片时,需确保图片是有效链接,且允许跨域获取,否则不支持导出图片

查看官网:vxetable.cn
gitbub:github.com/x-extends/v…
gitee:gitee.com/x-extends/v…

image

image

通过定义 cellRender 为 VxeImage 来导出图片格式

<template>
  <div>
    <vxe-button status="primary" @click="exportEvent">高级导出</vxe-button>
    <vxe-grid ref="gridRef" v-bind="gridOptions"></vxe-grid>
  </div>
</template>

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

const gridRef = ref()

const imgUrlCellRender = reactive({
  name: 'VxeImage',
  props: {
    width: 36,
    height: 36
  }
})

const gridOptions = reactive({
  border: true,
  showFooter: true,
  showOverflow: true,
  columnConfig: {
    resizable: true
  },
  editConfig: {
    trigger: 'click',
    mode: 'cell'
  },
  mergeCells: [
    { row: 0, col: 2, rowspan: 1, colspan: 2 },
    { row: 2, col: 2, rowspan: 2, colspan: 1 }
  ],
  exportConfig: {
    type: 'xlsx'
  },
  menuConfig: {
    body: {
      options: [
        [
          { code: 'EXPORT_ALL', name: '导出.xlsx' }
        ]
      ]
    }
  },
  columns: [
    { field: 'checkbox', type: 'checkbox', width: 70 },
    { field: 'seq', type: 'seq', width: 70 },
    { field: 'name', title: 'Name', editRender: { name: 'VxeInput' } },
    { field: 'code', title: '字符串类型', cellType: 'string', editRender: { name: 'VxeInput' } },
    {
      title: '分组1',
      children: [
        { field: 'num1', title: '数值', editRender: { name: 'VxeNumberInput', props: { type: 'float' } } },
        { field: 'num2', title: '数值(负数标红)', editRender: { name: 'VxeNumberInput', showNegativeStatus: true, props: { type: 'float' } } }
      ]
    },
    { field: 'amount1', title: '货币', editRender: { name: 'VxeNumberInput', props: { type: 'amount', showCurrency: true } } },
    { field: 'amount2', title: '货币(负数标红)', editRender: { name: 'VxeNumberInput', showNegativeStatus: true, props: { type: 'amount', showCurrency: true } } },
    { field: 'imgUrl', title: '图片', width: 80, cellRender: imgUrlCellRender }
  ],
  data: [
    { id: 10001, name: '张三', code: '000528697411', num1: 0.32, num2: -0.11, amount1: 1000000000, amount2: -0.11, imgUrl: 'https://vxeui.com/resource/img/fj586.png' },
    { id: 10002, name: '李四', code: '001236598563', num1: null, num2: 100000, amount1: -990000, amount2: 4, imgUrl: 'https://vxeui.com/resource/img/fj573.jpeg' },
    { id: 10003, name: '王五', code: '001499675653', num1: 100000, num2: null, amount1: 1, amount2: 100000, imgUrl: 'https://vxeui.com/resource/img/fj567.jpeg' },
    { id: 10004, name: '老六', code: '002568967545', num1: -0.11, num2: 1000000000, amount1: null, amount2: 1000000000, imgUrl: 'https://vxeui.com/resource/img/fj577.jpg' },
    { id: 10005, name: '小七', code: '005233368777', num1: -990000, num2: 28, amount1: 100000, amount2: null, imgUrl: 'https://vxeui.com/resource/img/bq673.gif' },
    { id: 10006, name: '小八', code: '000369877575', num1: 1000000000, num2: -990000, amount1: -0.11, amount2: -990000, imgUrl: 'https://vxeui.com/resource/img/fj124.jpeg' }
  ],
  footerData: [
    { checkbox: '合计', name: '12人', no1: 356 }
  ]
})

const exportEvent = () => {
  const $grid = gridRef.value
  if ($grid) {
    $grid.openExport()
  }
}
</script>

点击导出后效果

image

gitee.com/x-extends/v…

从零实现一个“类微信”表情输入组件

📋 背景

最近接到一个需求:在系统中添加表情输入功能。由于需要与腾讯某平台保持数据一致,表情包的数量和取值都要完全相同。

翻阅文档后发现并没有提供现成组件,只能自己实现。

先观察了实现方式:打开控制台面板,点击表情后会瞬间请求大量图片。

输入逻辑上,用户选择 😊 后值自动变成 [微笑],用户直接输入 [微笑] 也能映射成对应表情图片。

🚩 最终目标

  • ✅ 支持点击表情面板插入表情
  • ✅ 支持输入 [微笑] 自动转换为表情图片
  • ✅ 完成双向绑定,取值时图片转回 [微笑] 文本
  • ✅ 字符长度计算:中文 1 个字符,英文 0.5 个,表情 1 个
  • ✅ 光标定位准确,体验流畅
  • ✅ 输入法友好,不在拼音输入阶段转换

🧩 实现步骤

1、获取表情包数据

最初尝试在网上找表情包资源,但数量总是对不上。近百个表情包如果手动逐个校对太过折磨,于是尝试从页面爬取数据。 在浏览器控制台执行以下脚本:

// 获取所有表情元素
const emojiItems = document.querySelectorAll('.emoji-list li');

// 提取关键信息:图片地址、文本代码、文件名
const emojiArray = Array.from(emojiItems).map(li => {
  const img = li.querySelector('img');
  return img ? {
    src: img.src,
    alt: img.alt,
    dataImage: img.getAttribute('data-image')
  } : null;
}). filter(Boolean);

console.log(emojiArray);

执行后直接在控制台复制数组,保存为 JSON 文件。

src 只做下载使用,alt 需要用来做映射,dataImage 用于拼接读取表情图片路径。

[  {    "alt": "[微笑]",    "src": "https://xxx.qq.com/xxx/emojis/smiley_0.png"    "dataImage": "smiley_0"  }]

2、批量下载图片

使用 Node. js 脚本批量下载表情图片:

const fs = require('fs');
const path = require('path');

const outputDir = path. resolve(__dirname, 'emojis');
if (!fs.existsSync(outputDir)) {
  fs.mkdirSync(outputDir, { recursive: true });
}

async function downloadImage(item) {
  const fileName = `${item.dataImage || item.alt || 'emoji'}. png`;
  const filePath = path.join(outputDir, fileName);
  
  try {
    const res = await fetch(item.src);
    if (!res.ok) throw new Error(`Failed to fetch ${item.src}`);
    
    const buffer = await res.arrayBuffer();
    fs.writeFileSync(filePath, Buffer.from(buffer));
    console.log(`Downloaded: ${fileName}`);
  } catch (err) {
    console.error(`Error downloading ${item.src}:`, err.message);
  }
}

async function downloadAll() {
  for (const item of emojiArray) {
    await downloadImage(item);
  }
  console.log('All downloads completed!');
}

downloadAll();

表情下载完,后面就好办了——交给 AI 🤖。

3、组件实现

大概思路是有的,本质就是一个 contenteditable 的 div,组件实现完全让 AI 完成,但需要考虑一些特殊情况,要给出明确期望:

1️⃣ 光标位置管理
  • 长度达到限制时裁剪字符,统一将光标设置到末尾,防止位置异常(element-plus输入框效果如此)
  • 点击空白区域自动定位到末尾
2️⃣ 文本与图片双向转换

文本 → 图片

  • 使用防抖(200ms)避免频繁触发
  • 仅转换光标前的内容,光标后的内容保持不变
  • 通过正则匹配 [xxx] 格式进行转换
  • 关键:不在用户输入过程中转换,避免干扰输入体验

图片 → 文本

  • 文本节点直接提取 textContent
  • 图片节点提取 data-alt 属性转为文本
  • 过滤零宽字符 \u200B
3️⃣ 字符长度计算

精确计算混合内容长度:中文 1 个字符,英文/数字 0.5 个,表情 1 个。

4️⃣ 输入法兼容

处理中文输入法的组合事件:compositionstartcompositionupdatecompositionend,避免在拼音输入阶段触发转换。

5️⃣ 删除表情

确保删除时完整移除表情图片节点,不留残留。

最终效果:

动画.gif

💡总结

借助 AI 辅助开发,核心在于:告知 AI 需要处理的特殊情况,以及不断测试和调优。

只要明确需求规范和边界情况,就能高效实现功能。怎么有种从开发转测试的感觉。

node全栈系列(七)-增加验证码登录

这是一个非常实用的功能,能有效防止暴力破解密码。

我们将采用 svg-captcha 库来生成验证码。这是一种不需要安装复杂图形库(如 Python 的 pillow 或 C++ 的 gd)的轻量级方案,生成的 SVG 图片在前端渲染非常清晰且速度快。

实现逻辑如下:

  1. 生成:前端请求验证码 -> 后端生成一个随机字符 + 一个唯一ID (UUID) -> 后端把 {UUID: 验证码} 存到内存中 -> 把 UUID 和 SVG 图片返给前端。
  2. 验证:前端登录时,把 账号 + 密码 + 验证码 + UUID 一起发给后端 -> 后端根据 UUID 找内存里的验证码 -> 对比是否一致。

前置文章可以查看专栏

第一步:后端安装依赖

你需要安装两个库:

  • svg-captcha: 生成验证码图片。
  • uuid: 生成唯一标识符(用来标记这张验证码属于谁)。
npm install svg-captcha uuid

第二步:后端代码实现 (routes/auth.js)

我们需要修改 routes/auth.js,增加获取验证码接口,并修改登录接口。

注意:为了简单起见,我们将验证码存在全局变量 Map 中(内存)。如果是生产环境集群部署,通常存在 Redis 里,但作为学习项目,用内存 Map 足够了。

routes/auth.js

import express from 'express'
import { body, validationResult } from 'express-validator'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import svgCaptcha from 'svg-captcha' // 引入验证码库
import { v4 as uuidv4 } from 'uuid' // 引入UUID
import { pool } from '../db/mysql.js'
import config from '../config.js'
import HttpError from '../utils/HttpError.js'

const router = express.Router()

// --- 全局变量:存储验证码 ---
// Key: uuid, Value: 验证码文字
const captchaStore = new Map()

// 1. 获取图形验证码接口
// GET /auth/captcha
router.get('/captcha', (req, res, next) => {
  try {
    // 生成验证码
    const captcha = svgCaptcha.create({
      size: 4, // 4个字符
      ignoreChars: '0o1i', // 排除容易混淆的字符
      noise: 2, // 干扰线条数量
      color: true, // 文字有颜色
      background: '#fff' // 背景色
    })

    // 生成一个唯一标识符
    const uuid = uuidv4()

    // 存入内存 (转成小写方便比对)
    captchaStore.set(uuid, captcha.text.toLowerCase())

    // 设置过期时间:5分钟后自动删除,防止内存泄露
    setTimeout(() => {
      captchaStore.delete(uuid)
    }, 5 * 60 * 1000)

    // 返回 SVG 图片代码和 UUID
    res.json({
      code: 200,
      message: '获取成功',
      data: {
        uuid: uuid,
        img: captcha.data // 这是 SVG 的 XML 字符串,前端可以直接渲染
      }
    })
  } catch (err) {
    next(err)
  }
})

// 2. 登录接口 (增加验证码校验)
router.post(
  '/login',
  [
    body('username').notEmpty().withMessage('账号不能为空'),
    body('password').notEmpty().withMessage('密码不能为空'),
    body('code').notEmpty().withMessage('验证码不能为空'), // 新增校验
    body('uuid').notEmpty().withMessage('验证码已失效,请刷新重试') // 新增校验
  ],
  async (req, res, next) => {
    try {
      const errors = validationResult(req)
      if (!errors.isEmpty()) throw new HttpError(400, errors.array()[0].msg)

      const { username, password, code, uuid } = req.body

      // --- 核心修改:校验验证码 ---
      const correctCode = captchaStore.get(uuid) // 从内存拿正确的码
      if (!correctCode) {
        throw new HttpError(400, '验证码已过期,请点击图片刷新')
      }
      if (correctCode !== code.toLowerCase()) {
        throw new HttpError(400, '验证码错误')
      }
      // 校验通过后,立马删除该验证码(防止重复使用)
      captchaStore.delete(uuid)
      // ------------------------

      // 下面是原有的登录逻辑
      const [users] = await pool.query('SELECT * FROM sys_users WHERE username = ?', [username])
      
      if (users.length === 0) throw new HttpError(400, '账号或密码错误')
      const user = users[0]

      if (user.status === 0) throw new HttpError(403, '账号已被停用')

      const isMatch = await bcrypt.compare(password, user.password)
      if (!isMatch) throw new HttpError(400, '账号或密码错误')

      const payload = { userId: user.id }
      const token = jwt.sign(payload, config.JWT_SECRET, { expiresIn: config.JWT_EXPIRES_IN })

      res.json({
        code: 200,
        message: '登录成功',
        data: { token }
      })
    } catch (err) { next(err) }
  }
)

// ... info 接口保持不变 ...

export default router

第三步:前端 API 封装 (api/auth.ts)

import request from '@/utils/request'

// 登录 (现在需要多传 code 和 uuid)
export function login(data: any) {
  return request({
    url: '/auth/login',
    method: 'post',
    data
  })
}

// 获取验证码
export function getCaptcha() {
  return request({
    url: '/auth/captcha',
    method: 'get'
  })
}

// 获取用户信息 (不变)
export function getUserInfo() {
  return request({
    url: '/auth/info',
    method: 'get'
  })
}

第四步:前端页面修改 (views/login/login.vue)

我们需要修改 Store 和 页面 UI。

1. 修改 Store (store/user.ts)

不需要修改 Store 的核心逻辑,因为 login action 只是透传参数。只要调用的时候传进去 {username, password, code, uuid} 即可。

2. 修改页面 UI (views/login/login.vue)

在密码框下面增加验证码输入框和图片。

<template>
  <div class="login-container">
    <div class="login-box">
      <h2 class="title">后台管理系统</h2>
      <el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" size="large">
        <el-form-item prop="username">
          <el-input 
            v-model="loginForm.username" 
            placeholder="请输入账号" 
            prefix-icon="User"
          />
        </el-form-item>
        
        <el-form-item prop="password">
          <el-input 
            v-model="loginForm.password" 
            placeholder="请输入密码" 
            prefix-icon="Lock"
            type="password" 
            show-password
          />
        </el-form-item>

        <!-- 新增:验证码区域 -->
        <el-form-item prop="code">
          <div class="flex w-full gap-2">
            <el-input 
              v-model="loginForm.code" 
              placeholder="验证码" 
              prefix-icon="Key"
              class="flex-1"
              @keyup.enter="handleLogin"
            />
            <!-- 验证码图片容器 -->
            <div 
              class="captcha-box cursor-pointer" 
              v-html="captchaSvg" 
              @click="refreshCaptcha"
              title="点击刷新"
            ></div>
          </div>
        </el-form-item>

        <el-button 
          type="primary" 
          class="w-full mt-4" 
          :loading="loading" 
          @click="handleLogin"
        >
          登 陆
        </el-button>
      </el-form>
    </div>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/store/modules/user'
import { ElMessage } from 'element-plus'
import { getCaptcha } from '@/api/auth' // 引入API

const router = useRouter()
const route = useRoute()
const userStore = useUserStore()

const loginFormRef = ref()
const loading = ref(false)
const captchaSvg = ref('') // 存储 SVG 图片代码

const loginForm = reactive({
  username: '', 
  password: '',
  code: '', // 验证码输入值
  uuid: ''  // 验证码唯一ID
})

const loginRules = {
  username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
  password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
  code: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
}

// 获取/刷新验证码
const refreshCaptcha = async () => {
  try {
    const res: any = await getCaptcha()
    // res.data 包含 { uuid, img }
    loginForm.uuid = res.data.uuid
    captchaSvg.value = res.data.img
    loginForm.code = '' // 刷新后清空输入框
  } catch (error) {
    console.error(error)
  }
}

const handleLogin = () => {
  loginFormRef.value.validate(async (valid: boolean) => {
    if (valid) {
      loading.value = true
      try {
        await userStore.login(loginForm)
        ElMessage.success('登录成功')
        const redirect = route.query.redirect as string
        router.push(redirect || '/')
      } catch (error) {
        // 登录失败(如验证码错误),自动刷新验证码
        refreshCaptcha()
      } finally {
        loading.value = false
      }
    }
  })
}

// 初始化时获取验证码
onMounted(() => {
  refreshCaptcha()
})
</script>

<style scoped>
.login-container {
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

.login-box {
  width: 400px;
  padding: 40px;
  background: white;
  border-radius: 10px;
  box-shadow: 0 10px 25px rgba(0,0,0,0.1);
}

.title {
  text-align: center;
  margin-bottom: 30px;
  font-size: 24px;
  font-weight: bold;
  color: #333;
}

/* 验证码图片样式 */
.captcha-box {
  width: 120px;
  height: 40px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f5f7fa;
}

/* 深度选择器:控制 SVG 尺寸适应容器 */
:deep(.captcha-box svg) {
  width: 100%;
  height: 100%;
}
</style>

总结变化点

  1. 后端

    • 引入 svg-captcha 库。
    • 新增 /captcha 接口,生成 SVG 并在内存(captchaStore Map)中记录 uuid -> code 的映射。
    • 修改 /login 接口,在验证账号前,先拿 uuid 去内存查,对比前端传来的 code。
  2. 前端

    • UI 上增加了输入框和 v-html 来显示 SVG。
    • 进入页面或点击图片时,调用 /captcha 接口,保存 uuid,渲染 img。
    • 登录时把 code 和 uuid 一起发给后端。

这样你就拥有了一个安全、轻量且体验流畅的图形验证码功能了!

vue3很丝滑的table表格向上滚动效果,多用于统计页面

效果如图, 上边无缝滚动

image.png

组件代码:

<template>
  <div class="table-scroll-wrapper" ref="wrapperRef" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
    <div class="table-scroll-content" ref="contentRef">
      <!-- 原始内容 -->
      <div ref="originalContentRef">
        <slot></slot>
      </div>
      <!-- 克隆内容用于无缝滚动 -->
      <div v-if="shouldScroll" ref="cloneContentRef">
        <slot></slot>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';

const props = defineProps({
  // 滚动速度(像素/帧)
  speed: {
    type: Number,
    default: 0.5
  },
  // 鼠标悬停是否暂停
  hoverPause: {
    type: Boolean,
    default: true
  },
  // 是否启用滚动
  enabled: {
    type: Boolean,
    default: true
  }
});

// 引用DOM元素
const wrapperRef = ref<HTMLElement>();
const contentRef = ref<HTMLElement>();
const originalContentRef = ref<HTMLElement>();
const cloneContentRef = ref<HTMLElement>();

// 滚动状态
const animationId = ref<number>();
const currentTop = ref(0);
const isPaused = ref(false);
const contentHeight = ref(0);

// 计算属性:是否需要滚动
const shouldScroll = computed(() => {
  return props.enabled && contentHeight.value > (wrapperRef.value?.offsetHeight || 0);
});

// 滚动动画函数
const scroll = () => {
  if (!shouldScroll.value || isPaused.value || !wrapperRef.value || !contentRef.value) {
    return;
  }

  // 向上滚动
  currentTop.value -= props.speed;
  contentRef.value.style.transform = `translateY(${currentTop.value}px)`;

  // 当滚动过原始内容高度时,重置位置实现无缝滚动
  if (Math.abs(currentTop.value) >= contentHeight.value) {
    currentTop.value = 0;
  }

  // 继续下一帧动画
  animationId.value = requestAnimationFrame(scroll);
};

// 开始滚动
const startScroll = () => {
  if (animationId.value) {
    cancelAnimationFrame(animationId.value);
  }
  if (shouldScroll.value) {
    scroll();
  }
};

// 停止滚动
const stopScroll = () => {
  if (animationId.value) {
    cancelAnimationFrame(animationId.value);
    animationId.value = undefined;
  }
};

// 鼠标进入处理
const handleMouseEnter = () => {
  if (props.hoverPause) {
    isPaused.value = true;
    stopScroll();
  }
};

// 鼠标离开处理
const handleMouseLeave = () => {
  if (props.hoverPause) {
    isPaused.value = false;
    startScroll();
  }
};

// 更新内容高度
const updateContentHeight = () => {
  if (originalContentRef.value) {
    contentHeight.value = originalContentRef.value.offsetHeight;
  }
};

// 监听窗口大小变化
const handleResize = () => {
  updateContentHeight();
  // 如果条件变化,重新控制滚动
  if (shouldScroll.value && !isPaused.value && props.enabled) {
    startScroll();
  } else if (!shouldScroll.value) {
    stopScroll();
    currentTop.value = 0;
    if (contentRef.value) {
      contentRef.value.style.transform = 'translateY(0)';
    }
  }
};

// 生命周期钩子
onMounted(async () => {
  // 等待DOM完全渲染
  await nextTick();
  updateContentHeight();

  // 开始滚动
  if (shouldScroll.value && props.enabled) {
    startScroll();
  }

  // 监听窗口大小变化
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  stopScroll();
  window.removeEventListener('resize', handleResize);
});
</script>

<style scoped>
.table-scroll-wrapper {
  width: 100%;
  height: 100%;
  overflow: hidden;
  position: relative;
}

.table-scroll-content {
  width: 100%;
  position: absolute;
  top: 0;
  left: 0;
  will-change: transform;
}

/* 确保表格行正确显示 */
:deep(.el-row) {
  width: 100%;
  box-sizing: border-box;
}
</style>

页面中的使用

<template>
<div style="height: 20.3rem; overflow: hidden; margin-top: .4rem;">
    <simple-scroll :speed="0.5" :hoverPause="true" v-if="MaintainList && MaintainList.length > 0">
      <el-row class="screen_ltable_cont" :key="index" v-for="(item, index) in MaintainList" :gutter="10">
        <el-col :span="6">
          <el-tooltip class="box-item" effect="dark" :content="item.deviceAdress" placement="top">
            <div class="textellipsis">{{ index }}</div>
          </el-tooltip>
        </el-col>
        <el-col :span="5">
          <span class="screen_tag screen_warning" v-if="item.status == '1'">维修中</span>
          <span class="screen_tag screen_primary" v-if="item.status == '2'">维修完成</span>
        </el-col>
      </el-row>
    </simple-scroll>
    </div>
</template>
<script setup>
import SimpleScroll from '../mycomponents/simpleScroll/index.vue';
</script>



vue3 的预编译模板

在 Vue 3 中,预编译模板(Precompiled Templates) 是指在 构建阶段 就将 Vue 模板(<template> 中的内容)编译为渲染函数(Render Function),而非在浏览器运行时动态编译的技术。其核心目标是优化性能、减少运行时开销,并规避浏览器环境的编译限制。

一、先明确:模板的两种编译方式

Vue 模板要被浏览器执行,最终必须转为 JavaScript 渲染函数(虚拟 DOM 描述)。编译过程分两种:

类型 编译时机 执行环境 核心特点
运行时编译(Runtime) 浏览器运行时 浏览器 模板字符串 → 渲染函数,需带编译器,体积大
预编译(Precompile) 项目构建阶段 构建工具(webpack/vite) <template> → 渲染函数,运行时无需编译器
Vue 3 的预编译模板,本质就是选择“构建时编译”,提前完成模板到渲染函数的转换。

二、预编译模板的核心作用

1. 减小运行时体积

Vue 3 的核心包分为两种:

  • vue:包含编译器 + 运行时(体积较大,约 10KB+ 额外开销);
    • @vue/runtime-dom:仅包含运行时(体积更小,约 3KB+)。 预编译后,项目运行时无需编译器,可直接引入 @vue/runtime-dom,大幅减小最终打包体积(尤其对小型项目/移动端友好)。

2. 提升运行时性能

模板编译是耗时操作(需解析 HTML 字符串、处理指令/插值、生成渲染函数)。预编译将这一步移到构建阶段,浏览器运行时直接执行现成的渲染函数,避免了 runtime 编译的性能开销,首屏渲染和组件更新速度更快。

3. 规避浏览器环境限制

运行时编译依赖 new Function() 解析模板,部分严格的浏览器安全策略(如 CSP 限制)会禁止该 API,导致模板无法编译。预编译生成的是纯 JS 函数,无此限制,兼容性更优。

4. 更早发现模板错误

编译过程中会校验模板语法(如闭合标签、指令格式、变量引用),错误会在构建阶段抛出(而非运行时),便于更早排查问题。

三、预编译模板的工作原理

以 Vue 3 + Vite 项目为例,预编译流程如下:

  1. 开发/构建阶段: - 项目中的 .vue 文件会被 @vue/compiler-sfc(Vue 单文件组件编译器)处理; - 编译器解析 <template> 中的 HTML 结构、指令(v-if/v-for)、插值({{ }})等; - 将模板转换为 优化后的渲染函数(包含虚拟 DOM 创建逻辑、指令处理逻辑)。

  2. 运行时阶段: - 打包后的代码中,.vue 组件的 render 选项已直接是预编译好的函数; - Vue 运行时只需执行该函数,生成虚拟 DOM,再渲染为真实 DOM,无需额外编译步骤。

示例:模板 → 预编译后的渲染函数

原始模板(.vue 文件):

<template>
  <div class="greeting">
    Hello, {{ name }}!
    <p v-if="show">Vue 3 预编译模板</p>
  </div>
</template>
<script setup>
const name = "Vue"; 
const show = true; 
</script>

预编译后生成的渲染函数(简化版):

import { createVNode as _createVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createBlock as _createBlock } from "@vue/runtime-dom"; 

export function render(_ctx, _cache, $props, $setup, $data, $options) { 

return _openBlock(), _createBlock(
"div", 
  { class: "greeting" }, 
  [ 
  _createVNode("text", null, "Hello, " + _toDisplayString(_ctx.name) + "!"), _ctx.show 
  ? _createVNode("p", null, "Vue 3 预编译模板") 
  : _createVNode("text", null, "") 
  ]
); 
} 

运行时 Vue 直接执行 render 函数,无需解析模板字符串。

四、如何启用预编译模板?

Vue 3 项目(尤其是 Vite 或 Vue CLI 搭建的项目)默认已启用预编译,无需额外配置:

  • Vite:内置 @vitejs/plugin-vue,自动处理 .vue 文件的模板预编译;

  • Vue CLI:基于 webpack,通过 vue-loader 集成 @vue/compiler-sfc,自动预编译模板。

关键配置验证(以 Vite 为例)

vite.config.js 中只需引入 Vue 插件,即自动开启预编译:

import { defineConfig } from "vite"; 
import vue from "@vitejs/plugin-vue"; 
// 自动处理 .vue 模板预编译 export default defineConfig({ plugins: [vue()], }); 

手动确认预编译生效

  • 打包后查看输出的 JS 文件,不会包含 vue 核心包(而是 @vue/runtime-dom);

  • 组件代码中无模板字符串,直接是 render 函数。

五、预编译的适用场景与注意事项

适用场景 - 绝大多数 Vue 3 项目(尤其是生产环境),优先使用预编译;

  • 对包体积、性能敏感的场景(如移动端、小程序);

  • 存在浏览器 CSP 限制的场景。

注意事项

  1. 仅支持静态模板:预编译仅对 .vue 文件中 <template> 标签的静态模板生效;若通过 template 选项传入动态字符串(如 template: '<div>{{ msg }}</div>'),仍会触发运行时编译(需引入完整 vue 包)。
// 不推荐:动态模板字符串,无法预编译,需运行时编译 
import { createApp } from "vue";
createApp({ 
  template: "<div>{{ msg }}</div>", // 运行时编译,需引入完整 vue 
  data() { return { msg: "Hello" }; }, 
}).mount("#app"); 
  1. 依赖构建工具:预编译依赖 Vite/webpack 等构建工具,纯 HTML 引入 Vue 3 时(如 <script src="vue.global.js"></script>),无法使用预编译,只能用运行时编译。

  2. 编译缓存:构建工具会缓存预编译结果,修改模板后需重新构建(开发环境热更新已自动处理)。

总结

Vue 3 的预编译模板,是将模板编译工作从“浏览器运行时”提前到“项目构建时”的优化技术。核心价值是 减小包体积、提升运行时性能、规避环境限制,且 Vue 3 生态的主流构建工具(Vite/Vue CLI)已默认集成,开箱即用。

简单说:预编译 = 提前编译模板 → 运行时直接用 → 更快、更小、更兼容

告别截断与卡顿:我的前端PDF导出优化实践

告别截断与卡顿:我的前端PDF导出优化实践

项目地址:SeamlessPDF

背景

在前端开发中,PDF导出是一个“看着简单,做起来坑多”的需求。最常用的 html2canvas + jsPDF 方案虽然成熟,但在处理长文档时经常面临三个“顽疾”:

  1. 内容截断:文字、表格经常从中间被“一刀切”,极不美观。
  2. 页面卡顿:渲染过程阻塞主线程,点击导出后页面直接“假死”。
  3. 导出缓慢:复杂页面动辄等待 8-10 秒,用户体验很不友好。

为了解决这些问题,我尝试重构了一套生成方案。通过像素级分页分析多进程渲染以及异步预生成策略,最终将导出时间从 8 秒降至 2 秒左右,配合预生成实现了“点击即下载”的体验。

本文主要分享一下核心思路和关键代码实现。

先看下优化后的效果

1764812010591_CA3F8FF5-22D1-4EC0-AF58-CEBDAB4FA1BA.gif

传统方案为何“由于”?

在动手优化前,我们需要明确问题的根源:

  • 截断原因:传统方案通常按 A4 纸高度固定切割 Canvas。这就像闭着眼睛切蛋糕,不管刀下是文字还是表格,切到哪算哪。
  • 卡顿原因html2canvas 运行在主线程,DOM 树越复杂,计算量越大,UI 渲染必然被阻塞。
  • 慢的原因:串行处理(页眉->内容->页脚),无法利用现代浏览器的多核性能。

核心优化方案

针对上述痛点,我设计了三个维度的优化策略:

一、像素级分页分析:解决内容截断

既然固定高度切割不可靠,我们就需要通过算法去寻找“安全”的切割线。

核心思路: 先将内容渲染为完整的 Canvas,然后在理论分页位置附近上下扫描像素。如果某一行全是白色(空白区域)或者是表格底边框,那就是一个完美的切割点。

关键代码实现page-break-analyzer.ts):

// 寻找最优分页线的核心逻辑
export function findOptimalPageBreak(
  startY: number,
  canvas: HTMLCanvasElement
): OptimalBreakPointResult {
  // 1. 优先向上搜索:保持上一页内容尽可能饱满
  for (let y = startY; y > 0; y--) {
    const analysis = analyzeLine(y, canvas);

    // 如果是纯白行,或者是表格底部的边框,则允许切割
    if (analysis.isCleanBreakPoint) {
      return { cutY: y + 1 };
    }
  }

  // 2. 向上没找到,尝试向下搜索(避免这一页太短)
  for (let y = startY + 1; y < canvas.height; y++) {
    // ...同上逻辑
  }

  // 3. 实在找不到(比如超长表格),只能强制切割,但避开边框区域
  return { cutY: safeCutY };
}

// 分析单行像素特征
function analyzeLine(y: number, canvas: HTMLCanvasElement) {
  const context = canvas.getContext("2d")!;
  // 获取该行像素数据
  const lineData = context.getImageData(0, y, canvas.width, 1).data;

  // 分析颜色分布:判断是否为纯白,或是否符合表格边框特征
  // ... 具体算法省略,主要是对比 RGB 值
  return {
    isCleanBreakPoint: isPureWhite || isTableBottomBorder
  };
}

通过这种“视觉检测”的方式,我们不再依赖 DOM 结构计算,而是直接基于渲染结果,从而彻底解决了文字和表格被腰斩的问题。

二、多进程渲染:利用 Site Isolation 解决卡顿与慢

为了不阻塞主线程,同时提升速度,我利用了浏览器的 Site Isolation(站点隔离) 机制。

核心思路: 创建隐藏的 iframe 来承担渲染任务。现代浏览器会为跨域或特定配置的 iframe 分配独立的渲染进程。我们将页眉、页脚、主体内容分发给不同的 iframe 并行渲染,既不卡顿主页,又快了不少。

并行渲染实现iframe-renderer.ts):

export async function renderElementsToCanvas(elements: PageElements) {
  // 提取当前页面的所有样式,传递给 iframe
  const pageStyles = await extractPageStyles();

  // 利用 Promise.all 并行启动三个 iframe 进行渲染
  const [header, content, footer] = await Promise.all([
    renderInIframe(elements.header, "header", pageStyles),
    renderInIframe(elements.content, "content", pageStyles),
    renderInIframe(elements.footer, "footer", pageStyles),
  ]);

  return { header, content, footer };
}

并行渲染实现 :

// 主线程发送任务
function renderInIframe(element: HTMLElement, id: string, styles: string) {
  const iframe = createHiddenIframe();
  // 通过 postMessage 传递序列化后的 DOM 和样式
  iframe.contentWindow.postMessage(
    {
      type: "RENDER",
      dom: serializeElement(element),
      styles: styles,
    },
    "*"
  );

  return waitForResponse(iframe); // 等待 Canvas 数据返回
}

通过这种方式,繁重的布局计算和绘制任务被转移到了后台进程,主页面依然保持丝滑响应。

三、异步预生成:实现“零等待”体验

技术上的优化有了,用户体验还能更好吗? 通常用户进入页面后,浏览内容需要时间。我们可以利用这段“空闲时间”偷偷在后台把 PDF 生成好。

策略实现

// 页面加载完成后,静默启动预生成
let pdfPromise: Promise<jsPDF> | null = null;

function onPageReady() {
  // 使用 requestIdleCallback 或延迟执行,不影响首屏加载
  setTimeout(() => {
    pdfPromise = generateIntelligentPdf({
      // ...传入配置
    });
  }, 1000);
}

// 用户点击下载按钮时
async function handleDownload() {
  const btn = document.getElementById("download-btn");
  btn.loading = true;

  // 直接等待 Promise 结果
  const pdf = await pdfPromise;
  pdf.save("report.pdf");

  btn.loading = false;
}

如果用户点击时预生成已完成,下载是瞬间的;如果未完成,用户也只需等待剩余的时间。

性能对比

在包含表格、图片的长文档(约4页)测试场景下:

指标 优化前 优化后 提升幅度
渲染耗时 ~8s ~2s 75%
页面交互 卡死不可动 保持响应 98%
内容完整度 频繁截断 智能分页 -

总结

这次优化主要通过三个手段解决了 PDF 导出的核心痛点:

  1. 像素检测代替固定切割,保证了内容的完整性。
  2. Iframe 多进程代替单线程渲染,解决了卡顿并提升了速度。
  3. 预生成策略优化了用户的主观等待时长。

虽然引入 iframe 和像素分析增加了代码复杂度,但对于对文档质量有要求的场景,这些投入是值得的。

项目代码已开源,如果你也遇到了类似问题,欢迎参考: 👉 SeamlessPDF

参考资料

uni-app D8 实战(小兔鲜)

1.填写订单

1.1 渲染基本信息

image.png

1.1.1 设置独立的分包(静态结构)

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

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync();
// 订单备注
const buyerMessage = ref('');
// 配送时间
const deliveryList = ref([
    { type: 1, text: '时间不限 (周一至周日)' },
    { type: 2, text: '工作日送 (周一至周五)' },
    { type: 3, text: '周末配送 (周六至周日)' }
]);
// 当前配送时间下标
const activeIndex = ref(0);
// 当前配送时间
const activeDelivery = computed(() => deliveryList.value[activeIndex.value]);
// 修改配送时间
const onChangeDelivery: UniHelper.SelectorPickerOnChange = (ev) => {
    activeIndex.value = ev.detail.value;
};
</script>

<template>
    <scroll-view scroll-y class="viewport">
        <!-- 收货地址 -->
        <navigator v-if="false" class="shipment" hover-class="none" url="/pagesMember/address/address?from=order">
            <view class="user">张三 13333333333</view>
            <view class="address">广东省 广州市 天河区 黑马程序员3</view>
            <text class="icon icon-right"></text>
        </navigator>
        <navigator v-else class="shipment" hover-class="none" url="/pagesMember/address/address?from=order">
            <view class="address">请选择收货地址</view>
            <text class="icon icon-right"></text>
        </navigator>

        <!-- 商品信息 -->
        <view class="goods">
            <navigator v-for="item in 2" :key="item" :url="`/pages/goods/goods?id=1`" class="item" hover-class="none">
                <image class="picture" src="https://yanxuan-item.nosdn.127.net/c07edde1047fa1bd0b795bed136c2bb2.jpg" />
                <view class="meta">
                    <view class="name ellipsis">ins风小碎花泡泡袖衬110-160cm</view>
                    <view class="attrs">藏青小花 130</view>
                    <view class="prices">
                        <view class="pay-price symbol">99.00</view>
                        <view class="price symbol">99.00</view>
                    </view>
                    <view class="count">x5</view>
                </view>
            </navigator>
        </view>

        <!-- 配送及支付方式 -->
        <view class="related">
            <view class="item">
                <text class="text">配送时间</text>
                <picker :range="deliveryList" range-key="text" @change="onChangeDelivery">
                    <view class="icon-fonts picker">{{ activeDelivery.text }}</view>
                </picker>
            </view>
            <view class="item">
                <text class="text">订单备注</text>
                <input class="input" :cursor-spacing="30" placeholder="选题,建议留言前先与商家沟通确认" v-model="buyerMessage" />
            </view>
        </view>

        <!-- 支付金额 -->
        <view class="settlement">
            <view class="item">
                <text class="text">商品总价:</text>
                <text class="number symbol">495.00</text>
            </view>
            <view class="item">
                <text class="text">运费:</text>
                <text class="number symbol">5.00</text>
            </view>
        </view>
    </scroll-view>

    <!-- 吸底工具栏 -->
    <view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
        <view class="total-pay symbol">
            <text class="number">99.00</text>
        </view>
        <view class="button" :class="{ disabled: true }">提交订单</view>
    </view>
</template>

<style lang="scss">
page {
    display: flex;
    flex-direction: column;
    height: 100%;
    overflow: hidden;
    background-color: #f4f4f4;
}

.symbol::before {
    content: '¥';
    font-size: 80%;
    margin-right: 5rpx;
}

.shipment {
    margin: 20rpx;
    padding: 30rpx 30rpx 30rpx 84rpx;
    font-size: 26rpx;
    border-radius: 10rpx;
    background: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/locate.png) 20rpx center / 50rpx no-repeat #fff;
    position: relative;

    .icon {
        font-size: 36rpx;
        color: #333;
        transform: translateY(-50%);
        position: absolute;
        top: 50%;
        right: 20rpx;
    }

    .user {
        color: #333;
        margin-bottom: 5rpx;
    }

    .address {
        color: #666;
    }
}

.goods {
    margin: 20rpx;
    padding: 0 20rpx;
    border-radius: 10rpx;
    background-color: #fff;

    .item {
        display: flex;
        padding: 30rpx 0;
        border-top: 1rpx solid #eee;

        &:first-child {
            border-top: none;
        }

        .picture {
            width: 170rpx;
            height: 170rpx;
            border-radius: 10rpx;
            margin-right: 20rpx;
        }

        .meta {
            flex: 1;
            display: flex;
            flex-direction: column;
            justify-content: center;
            position: relative;
        }

        .name {
            height: 80rpx;
            font-size: 26rpx;
            color: #444;
        }

        .attrs {
            line-height: 1.8;
            padding: 0 15rpx;
            margin-top: 6rpx;
            font-size: 24rpx;
            align-self: flex-start;
            border-radius: 4rpx;
            color: #888;
            background-color: #f7f7f8;
        }

        .prices {
            display: flex;
            align-items: baseline;
            margin-top: 6rpx;
            font-size: 28rpx;

            .pay-price {
                margin-right: 10rpx;
                color: #cf4444;
            }

            .price {
                font-size: 24rpx;
                color: #999;
                text-decoration: line-through;
            }
        }

        .count {
            position: absolute;
            bottom: 0;
            right: 0;
            font-size: 26rpx;
            color: #444;
        }
    }
}

.related {
    margin: 20rpx;
    padding: 0 20rpx;
    border-radius: 10rpx;
    background-color: #fff;

    .item {
        display: flex;
        justify-content: space-between;
        align-items: center;
        min-height: 80rpx;
        font-size: 26rpx;
        color: #333;
    }

    .input {
        flex: 1;
        text-align: right;
        margin: 20rpx 0;
        padding-right: 20rpx;
        font-size: 26rpx;
        color: #999;
    }

    .item .text {
        width: 125rpx;
    }

    .picker {
        color: #666;
    }

    .picker::after {
        content: '\e6c2';
    }
}

/* 结算清单 */
.settlement {
    margin: 20rpx;
    padding: 0 20rpx;
    border-radius: 10rpx;
    background-color: #fff;

    .item {
        display: flex;
        align-items: center;
        justify-content: space-between;
        height: 80rpx;
        font-size: 26rpx;
        color: #333;
    }

    .danger {
        color: #cf4444;
    }
}

/* 吸底工具栏 */
.toolbar {
    position: fixed;
    left: 0;
    right: 0;
    bottom: calc(var(--window-bottom));
    z-index: 1;

    background-color: #fff;
    height: 100rpx;
    padding: 0 20rpx;
    border-top: 1rpx solid #eaeaea;
    display: flex;
    justify-content: space-between;
    align-items: center;
    box-sizing: content-box;

    .total-pay {
        font-size: 40rpx;
        color: #cf4444;

        .decimal {
            font-size: 75%;
        }
    }

    .button {
        width: 220rpx;
        text-align: center;
        line-height: 72rpx;
        font-size: 26rpx;
        color: #fff;
        border-radius: 72rpx;
        background-color: #27ba9b;
    }

    .disabled {
        opacity: 0.6;
    }
}
</style>

结果:

image.png

1.1.2 封装订单API

image.png

1.1.3 获取订单的函数

image.png

1.1.4 封装类型声明文件

import type { AddressItem } from './address'

/** 获取预付订单 返回信息 */
export type OrderPreResult = {
  /** 商品集合 [ 商品信息 ] */
  goods: OrderPreGoods[]
  /** 结算信息 */
  summary: {
    /** 商品总价 */
    totalPrice: number
    /** 邮费 */
    postFee: number
    /** 应付金额 */
    totalPayPrice: number
  }
  /** 用户地址列表 [ 地址信息 ] */
  userAddresses: AddressItem[]
}

/** 商品信息 */
export type OrderPreGoods = {
  /** 属性文字,例如“颜色:瓷白色 尺寸:8寸” */
  attrsText: string
  /** 数量 */
  count: number
  /** id */
  id: string
  /** 商品名称 */
  name: string
  /** 实付单价 */
  payPrice: string
  /** 图片 */
  picture: string
  /** 原单价 */
  price: string
  /** SKUID */
  skuId: string
  /** 实付价格小计 */
  totalPayPrice: string
  /** 小计总价 */
  totalPrice: string
}

image.png

1.1.5 存储获取到的订单信息到ref

image.png

1.1.6 渲染获取到的数据到标签中

image.png

image.png

1.1.7 再在先前的购物车代码中添加一行结算代码

image.png 结果: image.png

1.2 收货地址

Vue2简单实现一个权限管理

以下是基于 Vue2 实现的基础权限管理方案,涵盖路由权限控制、按钮权限控制、指令封装等核心功能,适合中小型后台系统:

一、权限数据设计

首先定义用户权限结构(通常从后端接口获取):

// src/store/modules/auth.js
const state = {
  // 用户角色(如 ['admin', 'editor'])
  roles: [],
  // 用户拥有的权限码(如 ['user:add', 'user:edit'])
  permissions: []
};

const mutations = {
  SET_ROLES(state, roles) {
    state.roles = roles;
  },
  SET_PERMISSIONS(state, permissions) {
    state.permissions = permissions;
  }
};

const actions = {
  // 模拟从后端获取权限(实际项目中替换为接口请求)
  getAuth({ commit }) {
    return new Promise(resolve => {
      // 假设后端返回的权限数据
      const authData = {
        roles: ['editor'],
        permissions: ['dashboard:view', 'user:view', 'user:edit']
      };
      commit('SET_ROLES', authData.roles);
      commit('SET_PERMISSIONS', authData.permissions);
      resolve();
    });
  }
};

export default {
  namespaced: true,
  state,
  mutations,
  actions
};

二、路由权限控制

通过路由守卫过滤无权限的路由,结合VueRouter的动态路由添加:

1. 定义路由规则(区分公开 / 私有路由)

// src/router/index.js
import Vue from 'vue';
import Router from 'vue-router';
import store from '@/store';

Vue.use(Router);

// 公开路由(无需权限)
export const constantRoutes = [
  {
    path: '/login',
    component: () => import('@/views/login')
  },
  {
    path: '/404',
    component: () => import('@/views/404')
  }
];

// 私有路由(需权限)
export const asyncRoutes = [
  {
    path: '/',
    component: () => import('@/layout'),
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard'),
        meta: { 
          title: '首页', 
          roles: ['admin', 'editor'], // 需要的角色
          permissions: ['dashboard:view'] // 需要的权限码
        }
      }
    ]
  },
  {
    path: '/user',
    component: () => import('@/layout'),
    children: [
      {
        path: 'list',
        component: () => import('@/views/user/list'),
        meta: { 
          title: '用户列表', 
          permissions: ['user:view'] 
        }
      },
      {
        path: 'edit/:id',
        component: () => import('@/views/user/edit'),
        meta: { 
          title: '编辑用户', 
          permissions: ['user:edit'] 
        }
      }
    ]
  },
  { path: '*', redirect: '/404', hidden: true }
];

const createRouter = () => new Router({
  routes: constantRoutes
});

const router = createRouter();

export default router;

2. 路由守卫实现权限过滤

// src/permission.js
import router from './router';
import store from './store';
import { asyncRoutes, constantRoutes } from './router';

// 权限判断函数:检查是否有角色/权限
function hasPermission(roles, permissions, route) {
  if (route.meta && route.meta.roles) {
    // 角色校验
    return roles.some(role => route.meta.roles.includes(role));
  }
  if (route.meta && route.meta.permissions) {
    // 权限码校验
    return permissions.some(perm => route.meta.permissions.includes(perm));
  }
  return true; // 无权限配置则默认可见
}

// 过滤私有路由
function filterAsyncRoutes(routes, roles, permissions) {
  const res = [];
  routes.forEach(route => {
    const tmp = { ...route };
    if (hasPermission(roles, permissions, tmp)) {
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, roles, permissions);
      }
      res.push(tmp);
    }
  });
  return res;
}

router.beforeEach(async (to, from, next) => {
  // 模拟已登录(实际项目中用token判断)
  const hasToken = true;
  if (hasToken) {
    if (to.path === '/login') {
      next({ path: '/' });
    } else {
      // 判断是否已获取权限
      const hasRoles = store.getters['auth/roles'].length > 0;
      if (hasRoles) {
        next();
      } else {
        try {
          // 获取权限数据
          await store.dispatch('auth/getAuth');
          const roles = store.getters['auth/roles'];
          const permissions = store.getters['auth/permissions'];
          // 过滤并添加动态路由
          const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles, permissions);
          router.addRoutes(accessedRoutes);
          // 确保路由添加完成后跳转
          next({ ...to, replace: true });
        } catch (err) {
          next(`/login?redirect=${to.path}`);
        }
      }
    }
  } else {
    // 未登录则跳转登录页
    if (to.path === '/login') {
      next();
    } else {
      next(`/login?redirect=${to.path}`);
    }
  }
});

三、按钮权限控制

通过自定义指令实现按钮级别的权限控制:

1. 注册权限指令

// src/directives/permission.js
import Vue from 'vue';
import store from '@/store';

/**
 * v-permission: 按钮权限指令
 * 使用:<button v-permission="'user:add'">新增</button>
 */
Vue.directive('permission', {
  inserted(el, binding) {
    const { value } = binding;
    const permissions = store.getters['auth/permissions'];
    // 校验权限(支持数组:v-permission="['user:add', 'user:edit']")
    if (value) {
      const hasPerm = Array.isArray(value) 
        ? permissions.some(perm => value.includes(perm)) 
        : permissions.includes(value);
      if (!hasPerm) {
        el.parentNode && el.parentNode.removeChild(el); // 无权限则移除元素
      }
    }
  }
});

/**
 * v-role: 角色权限指令
 * 使用:<button v-role="'admin'">管理员按钮</button>
 */
Vue.directive('role', {
  inserted(el, binding) {
    const { value } = binding;
    const roles = store.getters['auth/roles'];
    if (value) {
      const hasRole = Array.isArray(value) 
        ? roles.some(role => value.includes(role)) 
        : roles.includes(value);
      if (!hasRole) {
        el.parentNode && el.parentNode.removeChild(el);
      }
    }
  }
});

2. 全局引入指令

// src/main.js
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import store from './store';
import './directives/permission'; // 引入权限指令

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app');

四、页面中使用权限控制

<!-- src/views/user/list.vue -->
<template>
  <div>
    <h1>用户列表</h1>
    <!-- 按钮权限控制:只有user:add权限才显示 -->
    <button v-permission="'user:add'">新增用户</button>
    <!-- 角色控制:只有admin角色才显示 -->
    <button v-role="'admin'">删除用户</button>

    <!-- 表格内容 -->
    <el-table :data="userList">
      <el-table-column label="操作">
        <template slot-scope="scope">
          <!-- 编辑权限控制 -->
          <el-button v-permission="'user:edit'" @click="editUser(scope.row)">编辑</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

五、核心功能总结

  1. 路由权限:通过router.addRoutes动态添加有权限的路由,结合beforeEach守卫过滤无权限页面;
  2. 按钮权限:自定义v-permission/v-role指令,无权限时自动移除 DOM 元素;
  3. 权限存储:通过 Vuex 管理用户角色和权限码,支持从后端动态获取。

扩展建议

  • 实际项目中,权限数据需从后端接口获取(如登录后返回);
  • 可结合vue-i18n实现权限相关文案的国际化;
  • 复杂场景可增加 “数据权限”(如用户只能查看自己创建的数据),通过接口参数过滤。

这套方案轻量且易扩展,适合 Vue2 项目的基础权限管理需求。

还在为Vue应用的报错而头疼?这招让你彻底掌控全局

开发Vue应用时,最怕什么?不是复杂的逻辑,也不是难调的样式,而是那些不知从哪个角落里突然蹦出来的运行时错误。你正在测试一个新功能,页面突然白屏,控制台里一串红色错误,你却像大海捞针一样,半天找不到问题到底出在哪一层组件。用户那边更是直接反馈“页面打不开了”,你却只能干着急。

这种失控的感觉,真的很糟糕。错误就像是应用里的“暗雷”,你不知道它什么时候会爆。但别担心,Vue其实给我们准备了强大的“排雷工具”——那就是 errorCaptured 生命周期钩子和全局错误处理机制。用好它们,你不仅能精准定位错误源头,还能优雅地降级处理,给用户一个体面的体验,而不是一个冷冰冰的白屏。今天,我们就来彻底搞定Vue的错误处理,让你成为应用的“安全总监”。

理解错误处理的两种境界:局部守卫与全局防线

在深入代码之前,我们先理清思路。Vue的错误处理可以分成两个层面,就像小区的安保系统。

第一个层面,是组件级别的“局部守卫”,也就是 errorCaptured 钩子。想象一下,每栋楼(组件)都有自己的保安(errorCaptured)。这个保安的职责很明确:盯住从这栋楼内部(当前组件)以及所有进入这栋楼的访客(子组件)身上发生的错误。一旦发现,他可以先进行初步处理,比如登记、尝试解决小问题,然后再决定是就地处理掉这个错误,还是继续向上级(父组件)报告。

第二个层面,是应用级别的“全局防线”,即 app.config.errorHandler。这就像是小区的中央监控室。所有从各个楼栋(组件)保安那里上报来的、没被就地解决的严重错误,最终都会汇集到这里。这里是最后一道屏障,也是你实现统一错误处理逻辑(比如发送错误日志到服务器、展示友好的全局错误页面)的最佳位置。

简单来说,errorCaptured 让你能沿着组件树“捕获”错误,而 errorHandler 让你能在最顶层“处理”错误。两者配合,才能构建完整的错误处理体系。

深入 errorCaptured:你的组件级错误捕手

errorCaptured 是Vue组件的一个生命周期钩子。当本组件以及它的子孙组件中发生错误时,这个钩子就会被调用。它接收三个参数,让你能掌握错误的全部信息。

让我们来看一个最基础的使用示例,假设我们有一个可能出错的子组件 UnstableComponent

// 父组件 Parent.vue
<template>
  <div>
    <h2>父组件区域</h2>
    <!-- 这里嵌套了一个可能不稳定的子组件 -->
    <UnstableComponent />
  </div>
</template>

<script setup>
import { onErrorCaptured } from 'vue'
import UnstableComponent from './UnstableComponent.vue'

// 使用 onErrorCaptured 钩子
onErrorCaptured((error, instance, info) => {
  // 参数1: error - 捕获到的实际错误对象
  console.error('捕获到子组件错误:', error.message)
  
  // 参数2: instance - 触发错误的组件实例(Vue 3中可能为null或proxy对象,取决于错误发生时机)
  console.log('错误发生在哪个组件实例附近:', instance)
  
  // 参数3: info - 一个字符串,指出错误发生的来源类型,例如:
  // 'render function' (渲染函数)
  // 'watcher callback' (侦听器回调)
  // 'event handler' (事件处理器)
  // 'lifecycle hook' (生命周期钩子)
  console.log('错误来源:', info)
  
  // 这个钩子可以返回 false 来阻止错误继续向上冒泡
  // 如果这里返回 false,错误就不会传到更上层的 errorCaptured 或全局 errorHandler
  // 我们这里先不阻止,让错误继续上传以便观察
  return true
})
</script>
// 子组件 UnstableComponent.vue
<template>
  <button @click="causeError">点我触发一个错误</button>
</template>

<script setup>
const causeError = () => {
  // 这是一个在事件处理函数中故意抛出的错误
  throw new Error('糟糕!子组件里的事件处理函数出错了!')
}
</script>

在这个例子里,当你点击按钮,错误会在子组件中抛出。父组件的 onErrorCaptured 会立刻捕获到这个错误,并打印出详细信息。因为我们的钩子返回了 true(或者不返回任何值,默认行为是继续传播),这个错误会继续向更上层的组件“冒泡”。

关键点:errorCaptured 的返回值决定了错误的命运。 如果它返回 false,这个错误就被“消化”在此处,不会再向上传递。这非常有用,比如你可以用它来隔离一个非核心的、不稳定第三方组件的错误,避免它导致整个页面崩溃。

配置全局错误处理器:最后的安全网

当错误一路冒泡,穿过了所有组件的 errorCaptured 防线(或者它们都选择不拦截),最终就会到达全局处理器。这是你处理“未捕获异常”的最后机会,通常在这里我们会做几件关键事情:记录错误日志、展示用户友好的界面、尝试恢复应用状态。

设置它非常简单,在你的应用入口文件(通常是 main.jsmain.ts)里配置即可:

// main.js
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// 配置全局错误处理器
app.config.errorHandler = (error, instance, info) => {
  // 参数和 errorCaptured 钩子基本一致
  console.error('[全局错误拦截]', error)
  console.log('组件实例:', instance)
  console.log('错误来源:', info)
  
  // 1. 将错误信息发送到你的日志服务器(在实际项目中至关重要!)
  sendErrorToServer(error, info).catch(console.warn)
  
  // 2. 显示一个友好的全局错误提示,而不是白屏
  showGlobalErrorToast('应用发生了一点问题,我们正在紧急修复。')
  
  // 注意:全局处理器不能再阻止错误传播了,因为它是最后一站。
  // 这里的错误已经无法被Vue框架继续处理,但我们可以防止它导致整个页面崩溃。
}

// 模拟发送错误到后端服务的函数
async function sendErrorToServer(error, errorInfo) {
  // 在实际项目中,这里会调用你的API接口
  const errorLog = {
    message: error.message,
    stack: error.stack,
    component: errorInfo,
    url: window.location.href,
    timestamp: new Date().toISOString(),
    userAgent: navigator.userAgent
  }
  console.log('模拟发送错误日志到服务器:', errorLog)
  // 示例:await fetch('/api/log/error', { method: 'POST', body: JSON.stringify(errorLog) })
}

// 模拟显示一个全局提示
function showGlobalErrorToast(message) {
  // 这里可以使用你喜欢的UI库(如Element Plus, Ant Design Vue)的Message组件
  // 或者简单创建一个div来提示
  const toast = document.createElement('div')
  toast.textContent = message
  toast.style.cssText = `
    position: fixed;
    top: 20px;
    right: 20px;
    background-color: #fef0f0;
    color: #f56c6c;
    padding: 14px 20px;
    border-radius: 4px;
    border-left: 4px solid #f56c6c;
    z-index: 9999;
    box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
  `
  document.body.appendChild(toast)
  setTimeout(() => toast.remove(), 5000)
}

app.mount('#app')

全局处理器是你的“安全网”,确保即使有未预料的错误,应用也不会无声无息地崩溃,而是以一种可控的方式告知你和用户。

实战组合拳:构建一个健壮的错误处理流程

理论说完了,我们来点实际的。一个完整的错误处理流程,应该结合 errorCaptured 的精细控制和 errorHandler 的全局兜底。设想一个常见场景:你的应用里有一个显示用户动态的 Feed 组件,里面每一条动态由一个 FeedItem 子组件渲染。如果某一条动态的数据异常导致其子组件渲染失败,我们不应该让整个动态流白屏,而只是让那一条动态显示错误状态。

让我们来实现这个场景:

// Feed.vue (动态流父组件)
<template>
  <div class="feed">
    <h3>最新动态</h3>
    <!-- 循环渲染每条动态,用 error-boundary 包裹每一项 -->
    <div v-for="item in feedItems" :key="item.id" class="feed-item-wrapper">
      <!-- 关键:每个动态项都被一个“错误边界”组件包裹 -->
      <ErrorBoundary>
        <!-- Fallback 插槽定义错误时显示的内容 -->
        <template #fallback>
          <div class="feed-item-error">
            <span>这条动态暂时无法显示</span>
            <button @click="retryLoadItem(item.id)">重试</button>
          </div>
        </template>
        <!-- Default 插槽是正常要渲染的动态项 -->
        <template #default>
          <FeedItem :data="item" />
        </template>
      </ErrorBoundary>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import FeedItem from './FeedItem.vue'
import ErrorBoundary from './ErrorBoundary.vue' // 这是我们即将创建的错误边界组件

// 模拟动态数据,其中一条数据有问题
const feedItems = ref([
  { id: 1, content: '今天天气真好!' },
  { id: 2, content: '学习了一个新的Vue技巧。' },
  { id: 3, content: null }, // 这条数据的content为null,可能导致子组件渲染错误
  { id: 4, content: '晚餐吃了好吃的。' },
])

const retryLoadItem = (id) => {
  console.log(`重试加载动态 ${id}`)
  // 这里可以重新拉取数据或进行其他恢复操作
}
</script>

接下来,我们创建那个核心的 ErrorBoundary 组件。它的作用就是利用 errorCaptured 钩子,捕获其默认插槽内所有子组件的错误,并在出错时显示备用(fallback)UI。

// ErrorBoundary.vue (错误边界组件)
<template>
  <!-- 根据是否有错误,决定显示默认内容还是备用内容 -->
  <slot v-if="!hasError" />
  <slot v-else name="fallback" />
</template>

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

// 一个标志位,记录当前边界内是否发生了错误
const hasError = ref(false)

onErrorCaptured((error) => {
  console.warn('错误边界捕获到错误:', error.message)
  
  // 标记错误状态,这会触发模板切换,显示 fallback 插槽
  hasError.value = true
  
  // 返回 false,阻止错误继续向上冒泡到更外层的组件或全局处理器
  // 这样,一条动态的错误就不会影响整个Feed组件
  return false
})

// 可以提供一个重置错误状态的方法
const reset = () => {
  hasError.value = false
}

// 如果需要,可以将 reset 方法暴露给父组件
defineExpose({ reset })
</script>

最后,是可能不稳定的 FeedItem 组件:

// FeedItem.vue
<template>
  <div class="feed-item">
    <!-- 这里假设 content 必须是一个字符串,如果传入 null 就会出错 -->
    <p>{{ data.content.toUpperCase() }}</p> <!-- 当 data.content 为 null 时,.toUpperCase() 会抛出 TypeError -->
  </div>
</template>

<script setup>
defineProps({
  data: {
    type: Object,
    required: true
  }
})
</script>

看,这个设计的美妙之处在哪里?当渲染到第三条 contentnull 的动态时,FeedItem 会抛出错误。这个错误被其父级 ErrorBoundary 组件的 errorCaptured 钩子捕获。钩子将 hasError 设为 true,并返回 false 阻止错误上传。于是,模板切换为渲染 #fallback 插槽,用户看到的是“这条动态暂时无法显示”和一个重试按钮,而其他三条动态完全不受影响,全局错误处理器也根本不会收到这个错误的通知。

这种模式,就是前端领域常说的“错误边界”(Error Boundaries)概念在Vue中的实现。它极大地提升了应用的韧性。

一些重要的细节与陷阱

掌握了核心用法,我们还得聊聊那些容易踩坑的细节,让你真正从“会用”到“精通”。

第一,errorCaptured 能捕获所有错误吗? 很遗憾,不能。它主要捕获以下几类:

  1. 组件的渲染函数错误。
  2. 侦听器回调函数(watcher)里的错误。
  3. 生命周期钩子里的错误。
  4. 自定义事件处理函数($emit 触发的父组件回调)里的错误。 但是,异步回调(比如 setTimeoutPromise.catch 外部、接口请求的成功回调)中的错误,errorCaptured 是抓不到的。这些错误会逃逸到原生的 window.onerrorunhandledrejection 事件中。
// 示例:errorCaptured 无法捕获的错误
onMounted(() => {
  // 情况1:setTimeout 异步错误
  setTimeout(() => {
    throw new Error('异步setTimeout错误') // 这个错误 errorCaptured 抓不到!
  }, 1000)
  
  // 情况2:Promise 中未catch的错误
  someAsyncFunction().then(() => {
    throw new Error('Promise then回调错误') // 这个错误 errorCaptured 也抓不到!
  })
  // 正确做法是在Promise链内部捕获,或者用.catch
})

// 你需要用原生的全局错误监听来补足
window.addEventListener('unhandledrejection', event => {
  console.error('捕获到未处理的Promise拒绝:', event.reason)
  event.preventDefault() // 阻止浏览器默认的错误打印
})

第二,错误处理的顺序很重要。 错误冒泡的路径是:出错的组件本身(如果有errorCaptured)-> 父组件 -> 父组件的父组件 -> ... -> 全局 errorHandler。任何一个环节的 errorCaptured 返回 false,链条就会中断。

第三,关于服务端渲染(SSR)。 在SSR环境下(如Nuxt.js),errorCaptured 和客户端的行为一致。但 app.config.errorHandler 在服务器端和客户端是分开配置的。在Nuxt中,你可以使用 vueApp.config.errorHandler 在插件中配置,或者使用Nuxt提供的更高层级的错误处理机制。

总结:从手忙脚乱到从容应对

走完这一趟,你会发现,Vue的错误处理不再是黑盒。从细粒度的 errorCaptured 钩子到全局的 errorHandler,我们拥有了一套完整的工具来应对各种意外。

最清晰的思路是分层处理:

  • 在叶子组件或可能出错的特定组件周围,使用错误边界模式(利用 errorCaptured 返回 false)来隔离非关键错误,保证局部故障不影响全局。
  • 在应用的根层面,配置强大的全局错误处理器,用于记录所有未处理的错误、上报日志、并展示统一的友好提示,守住最后的用户体验底线。
  • 认识到局限性,用原生的 window.onerrorunhandledrejection 事件来补充捕获异步错误。

不要再让错误在你面前裸奔了。花点时间,为你的下一个Vue项目规划好错误处理策略。当错误再次发生时,你会看到清晰的日志、可控的界面,而不是用户的抱怨和你的困惑。这份从容,就是一个成熟开发者的标志。现在,就去你的项目中试试吧,从为一个组件添加第一个 onErrorCaptured 开始。

3.组合式函数

组合式函数

组合式函数,本质上也就是代码复用的一种方式。

  • 组件:对结构、样式、逻辑进行复用
  • 组合式函数:侧重于对 有状态 的逻辑进行复用

快速上手

实现一个鼠标坐标值的追踪器。

<template>
  <div>当前鼠标位置: {{ x }}, {{ y }}</div>
</template>

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

const x = ref(0)
const y = ref(0)

function update(event) {
  x.value = event.pageX
  y.value = event.pageY
}

onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>

<style scoped></style>

多个组件中复用这个相同的逻辑,该怎么办?

答:使用组合式函数。将包含了状态的相关逻辑,一起提取到一个单独的函数中,该函数就是组合式函数。

相关细节

1. 组合式函数本身还可以相互嵌套

2. 和Vue2时期mixin区别

解决了 Vue2 时期 mixin 的一些问题。

  1. 不清晰的数据来源:当使用多个 minxin 的时候,实例上的数据属性来自于哪一个 mixin 不太好分辨。

  2. 命名空间冲突:如果多个 mixin 来自于不同的作者,可能会注册相同的属性名,造成命名冲突

    mixin

    const mixinA = {
      methods: {
        fetchData() {
          // fetch data logic for mixin A
          console.log('Fetching data from mixin A');
        }
      }
    };
    
    const mixinB = {
      methods: {
        fetchData() {
          // fetch data logic for mixin B
          console.log('Fetching data from mixin B');
        }
      }
    };
    
    new Vue({
      mixins: [mixinA, mixinB],
      template: `
        <div>
          <button @click="fetchData">Fetch Data</button>
        </div>
      `
    });
    

    组合式函数:

    // useMixinA.js
    import { ref } from 'vue';
    
    export function useMixinA() {
      function fetchData() {
        // fetch data logic for mixin A
        console.log('Fetching data from mixin A');
      }
    
      return { fetchData };
    }
    
    // useMixinB.js
    import { ref } from 'vue';
    
    export function useMixinB() {
      function fetchData() {
        // fetch data logic for mixin B
        console.log('Fetching data from mixin B');
      }
    
      return { fetchData };
    }
    

    组件使用上面的组合式函数:

    import { defineComponent } from 'vue';
    import { useMixinA } from './useMixinA';
    import { useMixinB } from './useMixinB';
    
    export default defineComponent({
      setup() {
        // 这里必须要给别名
        const { fetchData: fetchDataA } = useMixinA();
        const { fetchData: fetchDataB } = useMixinB();
    
        fetchDataA();
        fetchDataB();
    
        return { fetchDataA, fetchDataB };
      },
      template: `
        <div>
          <button @click="fetchDataA">Fetch Data A</button>
          <button @click="fetchDataB">Fetch Data B</button>
        </div>
      `
    });
    
  3. 隐式的跨mixin交流

    mixin

    export const mixinA = {
      data() {
        return {
          sharedValue: 'some value'
        };
      }
    };
    
    export const minxinB = {
      computed: {
        dValue(){
          // 和 mixinA 具有隐式的交流
          // 因为最终 mixin 的内容会被合并到组件实例上面,因此在 mixinB 里面可以直接访问 mixinA 的数据
          return this.sharedValue + 'xxxx';
        }
      }
    }
    

    组合式函数:交流就是显式的

    import { ref } from 'vue';
    
    export function useMixinA() {
      const sharedValue = ref('some value');
      return { sharedValue };
    }
    
    import { computed } from 'vue';
    
    export function useMixinB(sharedValue) {
      const derivedValue = computed(() => sharedValue.value + ' extended');
      return { derivedValue };
    }
    
    <template>
      <div>
        {{ derivedValue }}
      </div>
    </template>
    
    <script>
    import { defineComponent } from 'vue';
    import { useMixinA } from './useMixinA';
    import { useMixinB } from './useMixinB';
    
    export default defineComponent({
      setup() {
        const { sharedValue } = useMixinA();
        
        // 两个组合式函数的交流是显式的
        const { derivedValue } = useMixinB(sharedValue);
    
        return { derivedValue };
      }
    });
    </script>
    

异步状态

根据异步请求的情况显示不同的信息:

<template>
  <div v-if="error">Oops! Error encountered: {{ error.message }}</div>
  <div v-else-if="data">
    Data loaded:
    <pre>{{ data }}</pre>
  </div>
  <div v-else>Loading...</div>
</template>

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

// 发送请求获取数据
const data = ref(null)
// 错误
const error = ref(null)

fetch('...')
  .then((res) => res.json())
  .then((json) => (data.value = json))
  .catch((err) => (error.value = err))
</script>

如何复用这段逻辑?仍然是提取成一个组合式函数。

如下:

import { ref } from 'vue'
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  fetch(url)
    .then((res) => res.json())
    .then((json) => (data.value = json))
    .catch((err) => (error.value = err))

  return { data, error }
}

现在重构上面的组件:

<template>
  <div v-if="error">Oops! Error encountered: {{ error.message }}</div>
  <div v-else-if="data">
    Data loaded:
    <pre>{{ data }}</pre>
  </div>
  <div v-else>Loading...</div>
</template>

<script setup>
import {useFetch} from './hooks/useFetch';
const {data, error} = useFetch('xxxx')
</script>

这里为了更加灵活,我们想要传递一个响应式数据:

const url = ref('first-url');
// 请求数据
const {data, error} = useFetch(url);
// 修改 url 的值后重新请求数据
url.value = 'new-url';

此时我们就需要重构上面的组合式函数:

import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  const fetchData = () => {
    // 每次执行 fetchData 的时候,重制 data 和 error 的值
    data.value = null
    error.value = null

    fetch(toValue(url))
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err))
  }

  watchEffect(() => {
    fetchData()
  })

  return { data, error }
}

约定和最佳实践

1. 命名:组合式函数约定用驼峰命名法命名,并以“use”作为开头。例如前面的 useMouse、useEvent.

2. 输入参数:注意参数是响应式数据的情况。如果你的组合式函数在输入参数是 ref 或 getter 的情况下创建了响应式 effect,为了让它能够被正确追踪,请确保要么使用 watch( ) 显式地监视 ref 或 getter,要么在 watchEffect( ) 中调用 toValue( )。

3. 返回值

组合式函数中推荐返回一个普通对象,该对象的每一项是 ref 数据,这样可以保证在解构的时候仍然能够保持其响应式的特性:

// 组合式函数
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  // ...
  
  return { x, y }
}
import { useMouse } from './hooks/useMouse'
// 可以解构
const { x, y } = useMouse()

如果希望以对象属性的形式来使用组合式函数中返回的状态,可以将返回的对象用 reactive 再包装一次即可:

import { useMouse } from './hooks/useMouse'
const mouse = reactive(useMouse())

4. 副作用

在组合式函数中可以执行副作用,例如添加 DOM 事件监听器或者请求数据。但是请确保在 onUnmounted 里面清理副作用。

例如在一个组合式函数设置了一个事件监听器,那么就需要在 onUnmounted 的时候移除这个事件监听器。

export function useMouse() {
  // ...

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

// ...
}

也可以像前面 useEvent 一样,专门定义一个组合式函数来处理副作用:

import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  // 专门处理副作用的组合式函数
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

5. 使用限制

  1. 只能在 <script setup>或 setup( ) 钩子中调用:确保在组件实例被创建时,所有的组合式函数都被正确初始化。特别如果你使用的是选项式 API,那么需要在 setup 方法中调用组合式函数,并且返回,这样才能暴露给 this 及其模板使用

    import { useMouse } from './mouse.js'
    import { useFetch } from './fetch.js'
    
    export default {
      setup() {
        // 因为组合式函数会返回一些状态
        // 为了后面通过 this 能够正确访问到这些数据状态
        // 必须在 setup 的时候调用组合式函数
        const { x, y } = useMouse()
        const { data, error } = useFetch('...')
        return { x, y, data, error }
      },
      mounted() {
        // setup() 暴露的属性可以在通过 `this` 访问到
        console.log(this.x)
      }
      // ...其他选项
    }
    
  2. 只能被同步调用:组合式函数需要同步调用,以确保在组件实例的初始化过程中,所有相关的状态和副作用都能被正确地设置和处理。如果组合式函数被异步调用,可能会导致在组件实例还未完全初始化时,尝试访问未定义的实例数据,从而引发错误。

  3. 可以在像 onMounted 生命周期钩子中调用:在某些情况下,可以在如 onMounted 生命周期钩子中调用组合式函数。这些生命周期钩子也是同步执行的,并且在组件实例已经被初始化后调用,因此可以安全地使用组合式函数。


-EOF-

vue3 上传文件,图片,视频组件

上传文件

<!-- eslint-disable vue/multi-word-component-names -->
<template>
  <div class="upload-file">
    <el-upload
      ref="uploadRef"
      :multiple="true"
      :action="uploadFileUrl"
      :before-upload="handleBeforeUpload"
      v-model="fileList"
      :file-list="fileList"
      :limit="limit"
      :on-error="handleUploadError"
      :on-exceed="handleExceed"
      :on-success="handleUploadSuccess"
      :show-file-list="false"
      :headers="headers"
      :auto-upload="true"
      class="upload-file-uploader"
    >
      <!-- 上传按钮 -->
      <el-button type="primary" v-show="isShow">选取文件</el-button>
    </el-upload>
    <!-- 上传提示 -->
    <div class="el-upload__tip" v-if="showTip" v-show="isShow">
      请上传
      <template v-if="fileSize">
        大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
      </template>
      <template v-if="fileType">
        格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b>
      </template>
  的文件
</div>
<!-- 文件列表 -->
<transition-group
  class="upload-file-list el-upload-list el-upload-list--text"
  name="el-fade-in-linear"
  tag="ul"
>
  <li
    :key="file.uid"
    class="el-upload-list__item ele-upload-list__item-content"
    v-for="(file, index) in fileList"
  >
    <el-link :underline="false" target="_blank">
      <span class="el-icon-document">
        {{ file.attachmentName }}
      </span>
    </el-link>
    <div class="ele-upload-list__item-content-action">
      <el-link v-show="isShow" :underline="false" @click="handleDelete(index)" type="danger"
        >删除</el-link
      >
      <el-link
        :underline="false"
        type="primary"
        @click="downloadFile(file)"
        v-if="dowloadStatus"
        >下载</el-link
      >
    </div>
  </li>
</transition-group>
<script lang="ts" setup>
import cache from '@/utils/cache';
import { log } from 'console';
import { ElMessage, UploadUserFile } from 'element-plus';
import { ref, computed, watch } from 'vue';
import { Download } from '@element-plus/icons-vue';
import { useUserStore } from '@/stores';
const uploadRef = ref();

const props = defineProps({
  modelValue: [String, Object, Array],
  // 数量限制
  limit: {
    type: Number,
    default: 5
  },
  // 大小限制(MB)
  fileSize: {
    type: Number,
    default: 1100
  },
  // 文件类型, 例如['png', 'jpg', 'jpeg']
  fileType: {
    type: Array,
    default: () => ['doc', 'xls', 'xlsx', 'pdf', 'docx']
  },
  // 是否显示提示
  isShowTip: {
    type: Boolean,
    default: true
  },
  //是否显示删除按钮
  isShow: {
    type: Boolean,
    default: true
  },
  //是否显示下载
  dowloadStatus: {
    type: Boolean,
    default: false
  }
});

// @ts-ignore
const { proxy } = getCurrentInstance();
// eslint-disable-next-line vue/valid-define-emits
const emit = defineEmits();
const number = ref(0);
const uploadFileUrl = import.meta.env.VITE_BASE_API + '/minio/upload'; // 上传文件服务器地址
const headers = ref({
  Authorization: 'Bearer ' + useUserStore().token,
  'bg-debug': 1
});
const fileList = ref<UploadUserFile[]>([]);
const showTip = computed(() => props.isShowTip && (props.fileType || props.fileSize));

watch(
  [() => props.modelValue, () => props.dowloadStatus],
  (val: any) => {
    if (val) {
      fileList.value = props.modelValue;
    }
  },
  { deep: true, immediate: true }
);
// 上传前校检格式和大小
function handleBeforeUpload(file: { name: string; size: number }) {
  // 校检文件类型
  if (props.fileType.length) {
    const fileName = file.name.split('.');
    const fileExt = fileName[fileName.length - 1];
    const isTypeOk = props.fileType.indexOf(fileExt) >= 0;
    if (!isTypeOk) {
      ElMessage.error(`文件格式不正确, 请上传${props.fileType.join('/')}格式文件!`);
      return false;
    }
  }
  // 校检文件大小
  if (props.fileSize) {
    const isLt = file.size / 1024 / 1024 < props.fileSize;
    if (!isLt) {
      ElMessage.error(`上传文件大小不能超过 ${props.fileSize} MB!`);
      return false;
    }
  }
  number.value++;
  return true;
}

//下载文件
const downloadPdf = (data: any) => {
  const fileName = data.attachmentName;
  const fileUrl = data.attachmentPath;
  const request = new XMLHttpRequest();
  request.responseType = 'blob';
  request.open('Get', fileUrl);
  request.onload = () => {
    const url = window.URL.createObjectURL(request.response);
    const a = document.createElement('a');
    document.body.appendChild(a);
    a.href = url;
    a.download = fileName;
    a.click();
  };
  request.send();
};

//下载文件
const downloadFile = (file: { attachmentPath: any; attachmentName: any }) => {
  const lastDotIdx = file.attachmentPath.lastIndexOf('.');
  const type = file.attachmentPath.slice(lastDotIdx + 1).toUpperCase();
  if (type === 'PDF') {
    downloadPdf(file);
  } else {
    const link = document.createElement('a');
    link.href = file.attachmentPath;
    link.download = file.attachmentName;
    document.body.appendChild(link);
    link.click();
  }
};
// 文件个数超出
function handleExceed() {
  ElMessage.error(`上传文件数量不能超过 ${props.limit} 个!`);
}

// 上传失败
function handleUploadError(err: any) {
  ElMessage.error('上传文件失败');
}
/** 文件上传成功处理 */
const handleUploadSuccess: UploadProps['onSuccess'] = (
  response: { data: { url: any } },
  file: { name: any }
) => {
  const newFile = { attachmentName: file.name, attachmentPath: response.data.url };
  fileList.value.push(newFile);
  uploadRef.value.submit();
  emit('update:modelValue', fileList.value);
};
// 删除文件
function handleDelete(index: number) {
  fileList.value.splice(index, 1);
  // @ts-ignore
  emit('update:modelValue', fileList.value);
}
</script>

<style scoped lang="scss">
.upload-file-uploader {
  margin-bottom: 5px;
}
.upload-file-list .el-upload-list__item {
  border: 1px solid #e4e7ed;
  line-height: 2;
  margin-bottom: 10px;
  position: relative;
}
.upload-file-list .ele-upload-list__item-content {
  display: flex;
  justify-content: space-between;
  align-items: center;
  color: inherit;
}
.ele-upload-list__item-content-action .el-link {
  margin-right: 10px;
  margin-left: 20px;
}
.ele-upload-list__item-content-action .el-icon {
  margin-right: 10px;
  margin-top: 10px;
}
</style>

上传图片

<template>
  <div class="pro-upload-img-box">
    <div class="pro-upload-img-content">
      <!-- 已上传图片列表 -->
      <div
        class="upload-img-card"
        v-for="(item, index) in fileList"
        :key="index"
      >
        <!-- 图片预览 -->
        <el-image
          class="img-sty"
          :preview-src-list="[item.url]"
          fit="cover"
          :src="item.url"
          alt=""
        />
        <!-- 删除按钮 -->
        <el-image
          v-if="!disabled"
          src="https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/img-close.png"
          class="img-close"
          @click="handleRemove(item, index)"
        />
        <!-- 图片遮罩层 -->
        <div class="img-mask">
          <el-image
            src="https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/img-preview.png"
            class="img-preview"
          />
        </div>
      </div>
      <!-- 上传组件 -->
      <el-upload
        v-loading="loading"
        ref="proUploadImgRef"
        :class="['pro-upload-img', { 'is-disabled': disabled }]"
        v-bind="uploadProps"
        :before-upload="beforeUpload"
        :on-success="handleSuccess"
        :on-error="handleError"
        :on-exceed="handleExceed"
      >
        <slot>
          <div class="upload-card">
            <el-icon class="upload-icon" style="font-size: 30px;">
              <CirclePlus  />
            </el-icon>
            <div v-if="uploadText" class="upload-text">
              {{ uploadText }}
            </div>
          </div>
        </slot>
      </el-upload>
    </div>
    <!-- 提示信息 -->
    <slot name="tip">
      <div class="upload-tip" v-if="tip">
        {{ tip }}
      </div>
    </slot>
  </div>
</template>

<script setup name="ProUploadImg">
  import { ref, computed } from 'vue';
  import { Plus } from '@element-plus/icons-vue';
  import { ElMessage } from 'element-plus';

  // Props 定义
  const props = defineProps({
    /** 上传地址 */
    action: {
      type: String,
      required: true,
    },
    /** 请求头 */
    headers: {
      type: Object,
      default: () => ({}),
    },
    /** 是否支持多选 */
    multiple: {
      type: Boolean,
      default: false,
    },
    /** 最大上传数量,0表示不限制 */
    limit: {
      type: Number,
      default: 0,
    },
    /** 接受的文件类型,如:.jpg,.png,.jpeg */
    accept: {
      type: String,
      default: '.jpg,.png,.jpeg',
    },
    /** 文件大小限制 */
    maxSize: {
      type: Number,
      default: 0,
    },
    /** 文件大小单位(KB/MB) */
    sizeUnit: {
      type: String,
      default: 'MB',
      validator: (value) => ['KB', 'MB'].includes(value),
    },
    /** 图片宽度限制 */
    width: {
      type: Number,
      default: 0,
    },
    /** 图片高度限制 */
    height: {
      type: Number,
      default: 0,
    },
    /** 上传提示文字 */
    uploadText: {
      type: String,
      default: '点击上传',
    },
    /** 上传提示说明 */
    tip: {
      type: String,
      default: '',
    },
    /** 是否禁用 */
    disabled: {
      type: Boolean,
      default: false,
    },
  });
  /** 初始文件列表 */
  const fileList = defineModel('fileList', {
    type: Array,
    default: () => [],
  });

  // 事件定义
  const emit = defineEmits(['success', 'error', 'exceed', 'remove']);

  const proUploadImgRef = ref();
  const loading = ref(false);

  const uploadProps = computed(() => ({
    action: props.action,
    accept: props.accept,
    limit: props.limit,
    multiple: props.multiple,
    listType: 'picture-card',
    showFileList: false,
    headers: props.headers,
    fileList: fileList.value,
    disabled: props.disabled,
  }));

  /**
   * 验证图片尺寸是否符合要求
   * @param {number} width - 图片宽度
   * @param {number} height - 图片高度
   * @returns {boolean} 是否符合要求
   */
  const validateImageSize = (width, height) => {
    if (props.width && props.height) {
      return width === props.width && height === props.height;
    }
    if (props.width) {
      return width === props.width;
    }
    if (props.height) {
      return height === props.height;
    }
    return true;
  };

  /**
   * 上传前校验
   * @param {File} file - 待上传的文件
   * @returns {Promise<boolean>} 是否通过校验
   */
  const beforeUpload = async (file) => {
    // 校验文件类型
    const fileTypeList = props.accept
      .split(',')
      .map((item) => item.replace('.', ''));
    const fileType = file.name.split('.').pop();

    if (!fileTypeList.includes(fileType)) {
      ElMessage({
        message: `仅支持 ${fileTypeList.join('、')} 格式`,
        type: 'warning',
      });
      return false;
    }

    // 校验文件大小
    if (props.maxSize) {
      const fileSize = file.size / 1024;
      const maxSizeInKB =
        props.sizeUnit === 'MB' ? props.maxSize * 1024 : props.maxSize;
      if (fileSize > maxSizeInKB) {
        ElMessage({
          message: `大小不能超过 ${props.maxSize}${props.sizeUnit}!`,
          type: 'warning',
        });
        return false;
      }
    }

    // 校验图片尺寸
    // return new Promise((resolve, reject) => {
    //   const img = new Image();
    //   img.src = URL.createObjectURL(file);
    //   img.onload = () => {
    //     URL.revokeObjectURL(img.src);
    //     const { width, height } = img;

    //     if (!validateImageSize(width, height)) {
    //       const message =
    //         props.width && props.height
    //           ? `图片尺寸必须为 ${props.width}x${props.height}`
    //           : props.width
    //             ? `图片宽度必须为 ${props.width}px`
    //             : `图片高度必须为 ${props.height}px`;

    //       ElMessage({
    //         message,
    //         type: 'warning',
    //       });
    //       reject(false);
    //       return;
    //     }
    //     loading.value = true;
    //     resolve(true);
    //   };
    //   img.onerror = () => {
    //     URL.revokeObjectURL(img.src);
    //     ElMessage({
    //       message: '图片加载失败',
    //       type: 'error',
    //     });
    //     reject(false);
    //   };
    // });
  };

  /**
   * 上传成功回调
   * @param {Object} response - 服务器响应数据
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleSuccess = (response, uploadFile, uploadFiles) => {
    console.log(response, uploadFile, uploadFiles,12345666)
    loading.value = false;
    if (response.code === 200) {
      fileList.value.push({ url: response.data.url });
      console.log(fileList.value,12345)
    } else {
      proUploadImgRef.value.handleRemove(uploadFile);
      ElMessage({
        message: response.msg || response.message || '上传失败',
        type: 'error',
      });
    }
    emit('success', response, uploadFile, uploadFiles);
  };

  /**
   * 上传失败回调
   * @param {Error} error - 错误信息
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleError = (error, uploadFile, uploadFiles) => {
    loading.value = false;
    ElMessage({
      message: '上传失败',
      type: 'error',
    });
    emit('error', error, uploadFile, uploadFiles);
  };

  /**
   * 超出限制回调
   * @param {Array} files - 超出限制的文件列表
   * @param {Array} uploadFiles - 已上传的文件列表
   */
  const handleExceed = (files, uploadFiles) => {
    ElMessage({
      message: `最多只能上传 ${props.limit} 张图片`,
      type: 'warning',
    });
    emit('exceed', files, uploadFiles);
  };

  /**
   * 移除图片
   * @param {Object} file - 要移除的文件对象
   * @param {number} index - 文件索引
   */
  const handleRemove = (file, index) => {
    fileList.value.splice(index, 1);
    proUploadImgRef.value.handleRemove(file);
    emit('remove', file);
  };
</script>

<style lang="scss" scoped>
.pro-upload-img-box {
  .pro-upload-img-content {
    display: flex;
    flex-wrap: wrap;
    // 已上传图片卡片样式
    .upload-img-card {
      width: 100px;
      height: 100px;
      position: relative;
      margin: 0 12px 12px 0;
      // 图片样式
      .img-sty {
        width: 100%;
        height: 100%;
        overflow: hidden;
        border-radius: 6px;
      }
      // 删除按钮样式
      .img-close {
        position: absolute;
        right: -6px;
        top: -6px;
        width: 20px;
        height: 20px;
        cursor: pointer;
        z-index: 2;
      }
      // 遮罩层样式
      .img-mask {
        background: rgba(0, 0, 0, 0.3);
        border-radius: 6px;
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        pointer-events: none;

        .img-preview {
          position: absolute;
          right: 8px;
          bottom: 8px;
          width: 20px;
          height: 20px;
          pointer-events: none;
        }
      }
    }

    // 禁用状态样式
    .is-disabled {
      :deep(.el-upload--picture-card) {
        cursor: not-allowed;
      }
    }
    // 上传按钮样式
    .pro-upload-img {
      margin-bottom: 12px;
      .upload-card {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        .upload-icon {
          font-size: 20px;
          color: #333;
          text-align: center;
          line-height: 100px;
        }

        .upload-text {
          line-height: 24px;
          color: #333;
          font-size: 14px;
          text-align: center;
          margin-top: 10px;
        }
      }
    }

    // 上传组件样式覆盖
    :deep(.el-upload--picture-card) {
      width: 100px;
      height: 100px;
      background-color: #F8F8F9;
    }
    :deep(.el-upload-list__item) {
      width: auto;
      height: auto;
      overflow: visible;
    }
  }
  // 提示文字样式
  .upload-tip {
    font-size: 12px;
    color: #909399;
  }
}
</style>

上传视频

<template>
  <div class="pro-upload-video-box">
    <div class="pro-upload-video-content">
      <!-- 已上传视频列表 -->
      <div
        class="upload-video-card"
        v-for="(item, index) in fileList"
        :key="index"
      >
        <!-- 视频缩略图/播放按钮 -->
        <div class="video-thumbnail" @click="playVideo(item.url)">
          <el-icon class="video-icon"><VideoPlay /></el-icon>
        </div>
        <!-- 视频信息 -->
        <div class="video-info" @click="playVideo(item.url)">
          <div class="video-name">{{ getFileName(item.url) }}</div>
          <div class="video-size">{{ getFileSize(item.size) }}</div>
        </div>
        <!-- 删除按钮 -->
        <el-image
          v-if="!disabled"
          src="https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/img-close.png"
          class="video-close"
          @click="handleRemove(item, index)"
        />
      </div>
      <!-- 上传组件 -->
      <el-upload
        v-if="!disabled"
        v-loading="loading"
        ref="proUploadVideoRef"
        :class="['pro-upload-video', { 'is-disabled': disabled }]"
        v-bind="uploadProps"
        :before-upload="beforeUpload"
        :on-success="handleSuccess"
        :on-error="handleError"
        :on-exceed="handleExceed"
        :on-progress="handleProgress"
      >
        <slot>
          <div class="upload-card">
            <el-icon class="upload-icon">
              <Plus />
            </el-icon>
            <div v-if="uploadText" class="upload-text">
              {{ uploadText }}
            </div>
          </div>
        </slot>
      </el-upload>
    </div>
    <!-- 提示信息 -->
    <slot name="tip"  v-if="!disabled">
      <div class="upload-tip" v-if="tip">
        {{ tip }}
      </div>
    </slot>
  </div>
</template>

<script setup name="ProUploadVideo">
  import { ref, computed } from 'vue';
  import { Plus, VideoPlay } from '@element-plus/icons-vue';
  import { ElMessage } from 'element-plus';

  // Props 定义
  const props = defineProps({
    /** 上传地址 */
    action: {
      type: String,
      required: true,
    },
    /** 请求头 */
    headers: {
      type: Object,
      default: () => ({}),
    },
    /** 是否支持多选 */
    multiple: {
      type: Boolean,
      default: false,
    },
    /** 最大上传数量,0表示不限制 */
    limit: {
      type: Number,
      default: 0,
    },
    /** 接受的文件类型,如:.mp4,.avi,.mov */
    accept: {
      type: String,
      default: '.mp4,.avi,.mov,.wmv,.flv,.webm',
    },
    /** 文件大小限制 */
    maxSize: {
      type: Number,
      default: 0,
    },
    /** 文件大小单位(KB/MB) */
    sizeUnit: {
      type: String,
      default: 'MB',
      validator: (value) => ['KB', 'MB'].includes(value),
    },
    /** 上传提示文字 */
    uploadText: {
      type: String,
      default: '上传视频',
    },
    /** 上传提示说明 */
    tip: {
      type: String,
      default: '',
    },
    /** 是否禁用 */
    disabled: {
      type: Boolean,
      default: false,
    },
  });
  /** 初始文件列表 */
  const fileList = defineModel('fileList', {
    type: Array,
    default: () => [],
  });

  // 事件定义
  const emit = defineEmits(['success', 'error', 'exceed', 'remove', 'deleteAnnex', 'progress']);

  const proUploadVideoRef = ref();
  const loading = ref(false);

  const uploadProps = computed(() => ({
    action: props.action,
    accept: props.accept,
    limit: props.limit,
    multiple: props.multiple,
    listType: 'text',
    showFileList: false,
    headers: props.headers,
    fileList: fileList.value,
    disabled: props.disabled,
  }));

  /**
   * 获取文件名
   * @param {string} url - 文件路径
   * @returns {string} 文件名
   */
  const getFileName = (url) => {
    if (!url) return '';
    const fileName = url.substring(url.lastIndexOf('/') + 1);
    return fileName.length > 15 ? fileName.substring(0, 15) + '...' : fileName;
  };

  /**
   * 获取文件大小显示
   * @param {number} size - 文件大小(字节)
   * @returns {string} 格式化后的文件大小
   */
  const getFileSize = (size) => {
    if (!size) return '';
    const units = ['B', 'KB', 'MB', 'GB'];
    let unitIndex = 0;
    let fileSize = size;

    while (fileSize >= 1024 && unitIndex < units.length - 1) {
      fileSize /= 1024;
      unitIndex++;
    }

    return `${fileSize.toFixed(2)} ${units[unitIndex]}`;
  };

  /**
   * 上传前校验
   * @param {File} file - 待上传的文件
   * @returns {Promise<boolean>} 是否通过校验
   */
  const beforeUpload = async (file) => {
    // 校验文件类型
    const fileTypeList = props.accept
      .split(',')
      .map((item) => item.replace('.', '').toLowerCase());
    const fileType = file.name.split('.').pop().toLowerCase();

    if (!fileTypeList.includes(fileType)) {
      ElMessage({
        message: `仅支持 ${props.accept} 格式`,
        type: 'warning',
      });
      return false;
    }

    // 校验文件大小
    if (props.maxSize) {
      const fileSize = file.size;
      const maxSizeInBytes =
        props.sizeUnit === 'MB' ? props.maxSize * 1024 * 1024 : props.maxSize * 1024;
      if (fileSize > maxSizeInBytes) {
        ElMessage({
          message: `大小不能超过 ${props.maxSize}${props.sizeUnit}!`,
          type: 'warning',
        });
        return false;
      }
    }

    loading.value = true;
    return true;
  };

  /**
   * 上传进度回调
   * @param {Object} event - 进度事件对象
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleProgress = (event, uploadFile, uploadFiles) => {
    emit('progress', event, uploadFile, uploadFiles);
  };

  /**
   * 上传成功回调
   * @param {Object} response - 服务器响应数据
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleSuccess = (response, uploadFile, uploadFiles) => {
    loading.value = false;
    if (response.code === 200) {
      fileList.value.push({
        url: response.data.url,
        name: uploadFile.name,
        size: uploadFile.size
      });
    } else {
      proUploadVideoRef.value.handleRemove(uploadFile);
      ElMessage({
        message: response.msg || response.message || '上传失败',
        type: 'error',
      });
    }
    emit('success', response, uploadFile, uploadFiles);
  };

  /**
   * 上传失败回调
   * @param {Error} error - 错误信息
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleError = (error, uploadFile, uploadFiles) => {
    loading.value = false;
    ElMessage({
      message: '上传失败',
      type: 'error',
    });
    emit('error', error, uploadFile, uploadFiles);
  };

  /**
   * 超出限制回调
   * @param {Array} files - 超出限制的文件列表
   * @param {Array} uploadFiles - 已上传的文件列表
   */
  const handleExceed = (files, uploadFiles) => {
    ElMessage({
      message: `最多只能上传 ${props.limit} 个视频`,
      type: 'warning',
    });
    emit('exceed', files, uploadFiles);
  };

  /**
   * 移除视频
   * @param {Object} file - 要移除的文件对象
   * @param {number} index - 文件索引
   */
  const handleRemove = (file, index) => {
    fileList.value.splice(index, 1);
    proUploadVideoRef.value.handleRemove(file);
    emit('deleteAnnex', index);
  };

  /**
   * 播放视频
   * @param {string} url - 视频地址
   */
  const playVideo = (url) => {
    if (!url) return;

    // 创建视频播放弹窗
    const videoDialog = document.createElement('div');
    videoDialog.style.cssText = `
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(0, 0, 0, 0.9);
      display: flex;
      justify-content: center;
      align-items: center;
      z-index: 9999;
    `;

    // 创建视频元素
    const videoWrapper = document.createElement('div');
    videoWrapper.style.cssText = `
      position: relative;
      max-width: 90%;
      max-height: 90%;
    `;

    // 创建加载提示
    const loadingIndicator = document.createElement('div');
    loadingIndicator.innerHTML = '视频加载中...';
    loadingIndicator.style.cssText = `
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      color: white;
      font-size: 16px;
      z-index: 1;
    `;
    videoWrapper.appendChild(loadingIndicator);

    const videoElement = document.createElement('video');
    videoElement.controls = true;
    videoElement.autoplay = true;
    videoElement.style.cssText = `
      max-width: 100%;
      max-height: 80vh;
      outline: none;
      background: black;
      display: none; /* 初始隐藏,等待加载完成后再显示 */
    `;

    // 尝试多种视频格式
    const fileExtension = url.split('.').pop().toLowerCase();
    const sourceElement = document.createElement('source');
    sourceElement.src = url;

    // 根据文件扩展名设置正确的 MIME 类型
    const mimeTypes = {
      'mp4': 'video/mp4',
      'webm': 'video/webm',
      'ogg': 'video/ogg',
      'avi': 'video/avi',
      'mov': 'video/quicktime',
      'wmv': 'video/x-ms-wmv',
      'flv': 'video/x-flv'
    };

    sourceElement.type = mimeTypes[fileExtension] || 'video/mp4';
    videoElement.appendChild(sourceElement);

    // 视频加载成功的处理
    videoElement.onloadeddata = () => {
      // 隐藏加载指示器并显示视频
      if (videoWrapper.contains(loadingIndicator)) {
        videoWrapper.removeChild(loadingIndicator);
      }
      videoElement.style.display = 'block';
    };

    // 视频加载失败的处理
    videoElement.onerror = () => {
      // 隐藏加载指示器
      if (videoWrapper.contains(loadingIndicator)) {
        videoWrapper.removeChild(loadingIndicator);
      }

      // 显示错误信息
      const errorIndicator = document.createElement('div');
      errorIndicator.innerHTML = '视频加载失败,请稍后重试';
      errorIndicator.style.cssText = `
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        color: #ff6b6b;
        font-size: 16px;
        background: rgba(0, 0, 0, 0.7);
        padding: 10px 20px;
        border-radius: 4px;
        z-index: 1;
      `;
      videoWrapper.appendChild(errorIndicator);

      // 3秒后自动关闭
      setTimeout(() => {
        if (document.body.contains(videoDialog)) {
          document.body.removeChild(videoDialog);
        }
      }, 3000);
    };

    // 创建关闭按钮
    const closeButton = document.createElement('button');
    closeButton.innerHTML = '&times;';
    closeButton.style.cssText = `
      position: absolute;
      top: -40px;
      right: 0;
      background: transparent;
      border: none;
      color: white;
      font-size: 36px;
      cursor: pointer;
      width: 40px;
      height: 40px;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: transform 0.2s;
    `;

    closeButton.onmouseover = () => {
      closeButton.style.transform = 'scale(1.1)';
    };

    closeButton.onmouseout = () => {
      closeButton.style.transform = 'scale(1)';
    };

    closeButton.onclick = () => {
      // 暂停视频并移除弹窗
      videoElement.pause();
      if (document.body.contains(videoDialog)) {
        document.body.removeChild(videoDialog);
      }
    };

    videoWrapper.appendChild(videoElement);
    videoWrapper.appendChild(closeButton);
    videoDialog.appendChild(videoWrapper);
    document.body.appendChild(videoDialog);

    // 点击背景关闭
    videoDialog.onclick = (e) => {
      if (e.target === videoDialog) {
        videoElement.pause();
        if (document.body.contains(videoDialog)) {
          document.body.removeChild(videoDialog);
        }
      }
    };

    // ESC键关闭
    const handleEscKey = (e) => {
      if (e.key === 'Escape') {
        videoElement.pause();
        if (document.body.contains(videoDialog)) {
          document.body.removeChild(videoDialog);
        }
        document.removeEventListener('keydown', handleEscKey);
      }
    };

    document.addEventListener('keydown', handleEscKey);
  };
</script>

<style lang="scss" scoped>
.pro-upload-video-box {
  .pro-upload-video-content {
    display: flex;
    // flex-wrap: wrap;
    // 已上传视频卡片样式
    .upload-video-card {
      width: 100%;
      max-width: 300px;
      height: 100px;
      position: relative;
      margin: 0 12px 12px 0;
      display: flex;
      align-items: center;
      border: 1px solid #ebeef5;
      border-radius: 6px;
      padding: 10px;
      box-sizing: border-box;

      // 视频缩略图样式
      .video-thumbnail {
        width: 50px;
        height: 50px;
        background-color: #ecf5ff;
        border-radius: 6px;
        display: flex;
        align-items: center;
        justify-content: center;
        margin-right: 10px;
        cursor: pointer;
        transition: all 0.3s;

        &:hover {
          background-color: #409eff;
          .video-icon {
            color: white;
          }
        }

        .video-icon {
          font-size: 24px;
          color: #409eff;
        }
      }

      // 视频信息样式
      .video-info {
        flex: 1;
        min-width: 0;
        cursor: pointer;

        .video-name {
          font-size: 14px;
          color: #606266;
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
          margin-bottom: 5px;
        }

        .video-size {
          font-size: 12px;
          color: #909399;
        }
      }

      // 删除按钮样式
      .video-close {
        position: absolute;
        right: -8px;
        top: -8px;
        width: 20px;
        height: 20px;
        cursor: pointer;
        z-index: 2;
      }
    }

    // 禁用状态样式
    .is-disabled {
      :deep(.el-upload--text) {
        cursor: not-allowed;
      }
    }
    // 上传按钮样式
    .pro-upload-video {
      margin-bottom: 12px;
      .upload-card {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        width: 100px;
        height: 100px;
        border: 1px dashed #d9d9d9;
        border-radius: 6px;
        cursor: pointer;
        transition: border-color 0.3s;

        &:hover {
          border-color: #409eff;
        }

        .upload-icon {
          font-size: 28px;
          color: #8c939d;
          margin-bottom: 5px;
        }

        .upload-text {
          line-height: 24px;
          color: #8c939d;
          font-size: 14px;
          text-align: center;
        }
      }
    }

    // 上传组件样式覆盖
    :deep(.el-upload) {
      width: auto;
      height: auto;
    }
  }
  // 提示文字样式
  .upload-tip {
    font-size: 12px;
    color: #909399;
  }
}
</style>

一次uniapp问题排查

最近在开发uniapp,虽然第一次写,但是有vue开发经验,也没觉得有啥不一样的,上手还是比较简单的,但是明显我还是低估了uniapp的复杂度,这东西难点不在于业务开发,而是各个不同机型的适配,相比于传统的web开发,还是繁琐了很多。

业务开发中遇到了一个蛮有趣的问题,这里总结下。

背景与环境

首先简单的描述下背景:就是在app登出的时候,发现了苹果6Plus在退出登录时表现不一样的地方,H5和安卓以及苹果16、ipad都表现正常,就是点击退出登录按钮,弹出一个弹窗,点击退出登录,跳转到到登录页,很常规的一个操作了,但是在苹果6Plus机型上,出现了异常,弹窗点击不动,页面出现了卡死,只能杀死应用重新进入。

这里贴下相关代码:

message.comform({
    msg:"确定退出登录嘛",
    title:"退出登录"
}).then(res => {
    await userStore.logout()
    uni.redirectTo({
        url:'/pages/login/index'
    })
})

这里就是清除下userStore中的状态,然后就跳转路由到登录页。

这里总结下uniapp路由跳转的方式

  1. uni.navigateTo 保留当掐你页面,跳转到某个应用内的页面
// 带参数跳转
uni.navigateTo({
  url: '/pages/detail/detail?id=123&name=test'
})

// 对象参数(需要编码)
uni.navigateTo({
  url: '/pages/detail/detail?data=' + encodeURIComponent(JSON.stringify({
    id: 123,
    name: 'test'
  }))
})
  1. uni.redirectTo 关闭当前页面 跳转到应用内的某个页面
uni.redirectTo({
  url: '/pages/home/home'
})
  1. uni.reLaunch 关闭所有页面,打开到应用内的某个页面
uni.reLaunch({
  url: '/pages/index/index'
})
  1. uni.switchTab 跳转到tabBar页面,并关闭其他所有非tabBar页面
uni.switchTab({
  url: '/pages/home/home'
})
  1. uni.navigateBack 返回上一级或者多级页面
// 返回上一页
uni.navigateBack()

// 返回多级页面
uni.navigateBack({
  delta: 2  // 返回2级
})

其中logout也是比较简单了,代码如下:

const layout = async () => { 
    removeToken()
    resetState()
}

也就是登录前移除token和store中的一些状态,看代码没有任何问题,但是就是在苹果6Plus中出现卡顿,很费解,光看代码看不出任何问题,就带着问题问了下gpt,知道了WKWebView 导航队列锁这个东西。

WKWebView 导航队列锁

WKWebView 导航队列锁定是 WebKit 内核中的一个线程安全机制,旨在防止在页面加载过程中的竞态条件。但在老版本中,这个机制实现得过于保守,导致并发导航操作容易被阻塞。

主要有这么几个场景会触发

  1. 快速连续导航调用

    // 微观时序问题 - 导航竞争
    const startTime = performance.now();
    
    // 导航请求1 - 第0ms
    uni.navigateTo({ url: '/pageA' });
    
    // 在导航1还未完成状态转换时...
    setTimeout(() => {
        // 导航请求2 - 第5ms (此时导航1可能还在 WKNavigationStateScheduled)
        uni.navigateTo({ url: '/pageB' });
    }, 5);
    
  2. 资源加载与导航竞争

    // WebKit 内部资源加载时序
    - (void)startNavigation:(WKNavigation *)navigation {
        [self acquireNavigationLock]; // 获取导航锁
        
        // 开始加载主文档
        [self loadMainDocument];
        
        // 此时如果主文档中有同步资源请求
        // <script src="sync-script.js"></script>
        // 资源加载会阻塞导航锁释放
        
        [self releaseNavigationLock]; // 延迟释放!
    }
    
  3. js桥接与导航交互

    // uni-app 框架层可能的问题
    // 1. 页面生命周期钩子与导航竞争
    export default {
        onLoad() {
            // 在 onLoad 中执行耗时操作
            this.loadHeavyData(); // 阻塞导航完成
        },
        
        onShow() {
            // 触发 UI 更新,需要渲染锁
            this.startAnimation(); 
        }
    }
    
    

    这个实例中,onLoad的触发时机是页面首次创建时 , onShow触发是页面显示的时候会触发,当onLoad中执行loadHeavyData耗时操作时,等页面显示执行onShow中的startAnimation就会导致竞争资源,就会触发队列锁,导致页面卡死,比较好的方法就是让他们再一个方法中执行,然后将他们的步骤拆分下,比如这样:

    onLoad() {
        // 阶段1: 立即执行 (导航锁持有期间)
        this.initUIState();
    
        // 阶段2: 延迟执行 (导航锁释放后)
        this.deferHeavyTask();
    
        // 阶段3: 空闲时执行
        this.idleNonCriticalTask();
    },
    
  4. css、js动画与js冲突

    function startAnimation() {
        const element = document.querySelector('.animated');
        element.style.transform = 'translateX(0)';
        
        // 触发 CSS 动画
        requestAnimationFrame(() => {
            element.style.transform = 'translateX(100px)';
            
            // 在同一帧内触发导航
            uni.navigateTo({ url: '/next' }); // 危险!
        });
    }
    

然后回到项目相关代码,似乎也没有很大的计算量导致资源竞争,触发队列锁,看下gpt5优化后的代码:

const clicking = ref(false)
const handleLogout = async () => {
    if(clicking.value) return
    click.value = false
    await message.confirm({msg:'确定退出登录?',title:'退出登录'})
    useStore.logOut()
    setTimeout(() => clicking.value=false,300)
}
const delay = ms => new Promise(r => setTimeout(r,ms))
const loggingOut = ref(false)
const LogOut = async () => {
    if(loggingOut.value) return
    loggingOut.value = true
    removeToken()
    resetState()
    uni.$emit?.('router:unlock')
    uni.$emit?.('ui:closs-popups')
    
    await delay(220)
    
    try {uni.reLaunch({url:'/pages/login/index'}) } catch {}
    setTimeout(() => try{uni.relaunch({url:'/pages/login/index'}) catch {}, 700)
    
    loggingOut.value = false
}

看了下代码,似乎也没有啥变更,只是都加了个开关,然后加了个延时操作,但是问题确实解决了。确实非常奇怪的问题。

前端组件二次封装实战:Vue+React基于Element UI/AntD的高效封装策略

在中后台项目开发中,Element UI(Vue)和Ant Design(AntD,React)是主流的组件库,但原生组件往往无法直接适配业务场景,比如:统一的表单校验规则、标准化的表格交互、个性化的弹窗样式等。此时,基于组件库的二次封装成为平衡开发效率、代码复用与团队规范的核心手段。我将围绕何时封装为何封装如何封装,三个核心问题,聚焦Element UI/AntD的二次封装技巧,结合Vue 3和React 18的实战案例,拆解高效且易扩展的封装方法论。

1. 什么时候值得封装一个组件

组件封装不是“为了封装而封装”,当满足以下场景时,二次封装的收益远大于成本:

1.1. 重复场景出现时:减少复制粘贴

当同一类UI/交互在2个及以上模块出现(如Element UI的Table+分页、AntD的Form+搜索按钮),且仅参数不同,封装可避免重复代码。

  • 示例:多个列表页都用Element UI的Table,且都需要“分页+多选+操作列”,封装BaseTable组件统一逻辑。

1.2. 业务规则需统一时:规避风格混乱

当组件需要遵循统一的业务规则(如按钮权限控制、日期格式渲染、表单校验提示),封装可收口规则。

  • 示例:AntD的Button需根据用户角色控制显示/禁用,封装AuthButton统一处理权限逻辑,所有页面复用。

1.3. 原生组件能力不足时:补齐个性化需求

Element UI/AntD的通用能力无法覆盖业务场景(如Element UI的Dialog需拖拽、AntD的Select需最多显示3个多选标签),二次封装可定制化扩展。

1.4. 逻辑与UI耦合复杂时:降低维护成本

当一个功能包含“数据请求+交互逻辑+样式定制”(如带远程搜索的部门选择器),封装可拆分复杂逻辑,符合单一职责原则。

2. 封装组件的核心目的

降本提效:一次封装,多处复用。后续需求变更(如表格分页样式调整),只需修改封装组件,所有引用处自动生效,无需逐个页面修改。

逻辑内聚:高内聚、低耦合。将业务逻辑(如数据请求、校验规则)封装在组件内部,页面只需关注“传参”和“接收结果”,降低代码耦合度。

扩展灵活:适配未来业务变化。预留扩展接口,新增需求(如表格新增导出功能)时,仅需扩展组件内部,不影响外部调用方式。

统一标准:对齐团队开发规范。避免不同开发者对Element UI/AntD的定制方式不一致(如按钮尺寸、表单间距),保证项目风格统一。

3. Element UI/AntD二次封装核心技巧:透传原生Props

二次封装的关键是“不丢失原生组件的能力”——即让封装后的组件能隐式传递原生组件的所有Props、事件和样式,同时新增业务逻辑。以下分Vue(Element Plus)和React(AntD)讲解核心实现方式。

核心概念:透传的本质

  • Vue:通过v-bind="$attrs"透传Props,v-on="$listeners"(Vue 3已合并到$attrs)透传事件,inheritAttrs: false避免属性透传到根元素。
  • React:通过扩展运算符{...props}透传所有Props,通过children透传子元素,区分“业务Props”和“原生Props”。

3.1. Vue 3 + Element Plus 二次封装实战

以封装BaseDialog(基于ElDialog)为例,实现“拖拽+默认样式+透传原生Props”:

步骤1:基础封装(透传原生Props)

<template>
  <!-- 根元素禁用属性继承,避免$attrs透传到div -->
  <div class="base-dialog">
    <el-dialog
      v-bind="$attrs" <!-- 透传ElDialog的所有原生Props(如title、visible、width) -->
      :close-on-click-modal="false" <!-- 业务默认值,可被外部Props覆盖 -->
      @close="handleClose" <!-- 内部处理基础事件,也可透传外部事件 -->
      class="base-dialog__inner"
    >
      <!-- 插槽:透传ElDialog的默认插槽 -->
      <slot />
      <!-- 插槽:自定义底部按钮 -->
      <template #footer>
        <slot name="footer">
          <!-- 默认底部按钮 -->
          <el-button @click="handleCancel">取消</el-button>
          <el-button type="primary" @click="handleConfirm">确认</el-button>
        </slot>
      </template>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ElDialog, ElButton, ElMessage } from 'element-plus';
// 引入拖拽指令(可选,扩展功能)
import { vDialogDrag } from '@/directives/dialogDrag';

// 禁用根元素的属性继承,确保$attrs只透传给ElDialog
defineOptions({
  inheritAttrs: false
});

// 定义业务Props(与原生Props区分)
const props = defineProps<{
  // 业务自定义Props,非ElDialog原生属性
  confirmText?: string;
  cancelText?: string;
}>();

// 定义事件:透传原生事件 + 自定义业务事件
const emit = defineEmits<{
  (e: 'confirm'): void; // 自定义确认事件
  (e: 'cancel'): void; // 自定义取消事件
  (e: 'close'): void; // 透传ElDialog的close事件
}>();

// 内部处理确认逻辑
const handleConfirm = () => {
  emit('confirm');
  // 可扩展:统一的确认提示
  ElMessage.success('操作成功');
};

// 内部处理取消逻辑
const handleCancel = () => {
  emit('cancel');
  // 触发ElDialog的关闭(通过透传的visible属性由外部控制)
  emit('close');
};

// 透传ElDialog的close事件
const handleClose = () => {
  emit('close');
};
</script>

<style scoped>
.base-dialog {
  --el-dialog-width: 600px; /* 自定义默认宽度,可被外部覆盖 */
}
.base-dialog__inner :deep(.el-dialog__header) {
  padding: 16px 20px;
  border-bottom: 1px solid #eee;
}
</style>

步骤2:指令扩展(拖拽功能)

// src/directives/dialogDrag.ts
import type { Directive } from 'vue';

export const vDialogDrag: Directive = {
  mounted(el) {
    const dialogHeaderEl = el.querySelector('.el-dialog__header');
    const dragDom = el.querySelector('.el-dialog') as HTMLElement;
    if (!dialogHeaderEl || !dragDom) return;

    // 设置拖拽元素可拖动
    dialogHeaderEl.style.cursor = 'move';
    dialogHeaderEl.addEventListener('mousedown', (e) => {
      // 鼠标按下,计算当前元素距离可视区的距离
      const disX = e.clientX - dialogHeaderEl.offsetLeft;
      const disY = e.clientY - dialogHeaderEl.offsetTop;
      const dragDomWidth = dragDom.offsetWidth;
      const dragDomHeight = dragDom.offsetHeight;
      const screenWidth = document.body.clientWidth;
      const screenHeight = document.body.clientHeight;

      // 最大移动距离
      const maxX = screenWidth - dragDomWidth;
      const maxY = screenHeight - dragDomHeight;

      // 鼠标移动事件
      const moveFn = (e: MouseEvent) => {
        let left = e.clientX - disX;
        let top = e.clientY - disY;

        // 边界处理
        if (left < 0) left = 0;
        if (left > maxX) left = maxX;
        if (top < 0) top = 0;
        if (top > maxY) top = maxY;

        dragDom.style.left = `${left}px`;
        dragDom.style.top = `${top}px`;
      };

      // 鼠标松开事件
      const upFn = () => {
        document.removeEventListener('mousemove', moveFn);
        document.removeEventListener('mouseup', upFn);
      };

      document.addEventListener('mousemove', moveFn);
      document.addEventListener('mouseup', upFn);
    });
  },
};

步骤3:父组件调用(透传原生Props + 扩展)

<template>
  <el-button @click="dialogVisible = true">打开弹窗</el-button>
  
  <!-- 调用封装后的BaseDialog,可透传ElDialog所有原生Props -->
  <BaseDialog
    v-model="dialogVisible" <!-- 透传ElDialog的visible属性(v-model语法糖) -->
    title="自定义弹窗"
    width="800px" <!-- 覆盖默认宽度 -->
    confirm-text="提交" <!-- 自定义业务Props -->
    @confirm="handleConfirm"
    @close="handleClose"
  >
    <div>弹窗内容</div>
    <!-- 自定义底部按钮(覆盖默认插槽) -->
    <template #footer>
      <el-button @click="dialogVisible = false">取消</el-button>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
    </template>
  </BaseDialog>
</template>

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

const dialogVisible = ref(false);

const handleConfirm = () => {
  console.log('确认');
  dialogVisible.value = false;
};

const handleClose = () => {
  console.log('关闭');
};

const handleSubmit = () => {
  console.log('自定义提交');
  dialogVisible.value = false;
};
</script>

3.2. React 18 + AntD 二次封装实战

以封装BaseTable(基于AntD的Table)为例,实现“分页封装+透传原生Props+统一操作列”:

步骤1:基础封装(区分业务Props与原生Props)

import React, { useState, useEffect } from 'react';
import { Table, Pagination, Space, Button, Typography } from 'antd';
import type { TableProps, PaginationProps } from 'antd';

// 定义业务Props:与AntD Table原生Props区分
interface BaseTableProps<T = any> extends Omit<TableProps<T>, 'pagination'> {
  // 业务自定义分页Props
  paginationConfig?: PaginationProps;
  // 统一操作列配置
  actionColumn?: {
    width?: number;
    fixed?: 'left' | 'right';
    // 操作项配置
    actions: {
      text: string;
      onClick: (record: T) => void;
      type?: 'primary' | 'default' | 'danger';
    }[];
  };
}

const BaseTable = <T,>({
  columns,
  dataSource,
  paginationConfig,
  actionColumn,
  ...restProps // 剩余Props:透传AntD Table的原生Props
}: BaseTableProps<T>) => {
  // 合并列配置:新增操作列
  const mergedColumns = React.useMemo(() => {
    const cols = [...(columns || [])];
    if (actionColumn) {
      cols.push({
        title: '操作',
        key: 'action',
        width: actionColumn.width || 200,
        fixed: actionColumn.fixed || 'right',
        render: (_, record) => (
          <Space size="small">
            {actionColumn.actions.map((action, index) => (
              <Button
                key={index}
                type={action.type || 'default'}
                onClick={() => action.onClick(record)}
              >
                {action.text}
              </Button>
            ))}
          </Space>
        ),
      });
    }
    return cols;
  }, [columns, actionColumn]);

  // 分页状态管理
  const [pagination, setPagination] = useState<PaginationProps>({
    current: 1,
    pageSize: 10,
    showSizeChanger: true,
    showQuickJumper: true,
    showTotal: (total) => `共 ${total} 条`,
    ...paginationConfig,
  });

  // 监听数据总数,更新分页
  useEffect(() => {
    if (paginationConfig?.total !== undefined) {
      setPagination(prev => ({ ...prev, total: paginationConfig.total }));
    }
  }, [paginationConfig?.total]);

  // 分页变更回调
  const handleTableChange = (
    pagination: PaginationProps,
    filters: any,
    sorter: any
  ) => {
    setPagination(pagination);
    // 透传原生onChange事件
    restProps.onChange?.(pagination, filters, sorter);
  };

  return (
    <div style={{ background: '#fff', padding: 16, borderRadius: 4 }}>
      {/* 透传AntD Table的所有原生Props */}
      <Table<T>
        columns={mergedColumns}
        dataSource={dataSource}
        pagination={false} // 禁用原生分页,自定义
        onChange={handleTableChange}
        bordered // 业务默认值,可被restProps覆盖
        {...restProps} // 透传剩余原生Props(如rowKey、loading、scroll)
      />
      {/* 自定义分页组件 */}
      <div style={{ marginTop: 16, textAlign: 'right' }}>
        <Pagination
          {...pagination}
          {...paginationConfig}
          onChange={(page, pageSize) => {
            setPagination(prev => ({ ...prev, current: page, pageSize }));
          }}
        />
      </div>
    </div>
  );
};

export default BaseTable;

步骤2:父组件调用(透传原生Props + 扩展)

import React from 'react';
import BaseTable from './components/BaseTable';
import { Button, message } from 'antd';

// 模拟数据
const dataSource = [
  { id: 1, name: '张三', age: 20, status: '启用' },
  { id: 2, name: '李四', age: 22, status: '禁用' },
];

const Page = () => {
  // 列配置
  const columns = [
    { title: '姓名', dataIndex: 'name', key: 'name' },
    { title: '年龄', dataIndex: 'age', key: 'age' },
    { title: '状态', dataIndex: 'status', key: 'status' },
  ];

  // 操作列配置
  const actionColumn = {
    width: 200,
    fixed: 'right',
    actions: [
      {
        text: '编辑',
        type: 'primary',
        onClick: (record) => {
          message.success(`编辑${record.name}`);
        },
      },
      {
        text: '删除',
        type: 'danger',
        onClick: (record) => {
          message.warning(`删除${record.name}`);
        },
      },
    ],
  };

  return (
    <div style={{ padding: 20 }}>
      <BaseTable
        rowKey="id" // 透传AntD Table原生Props
        columns={columns}
        dataSource={dataSource}
        scroll={{ x: 1000 }} // 透传原生Props横向滚动
        loading={false} // 透传原生Props加载状态
        paginationConfig={{
          total: 2,
          pageSize: 10,
        }}
        actionColumn={actionColumn}
        // 透传原生事件
        onRow={(record) => ({
          onClick: () => console.log('点击行', record),
        })}
      />
    </div>
  );
};

export default Page;

4. 高效且易扩展的封装原则

下面是一些封装时候的原则,Vue/React通用:

4.1. Props设计

分层透传,不丢失原生能力

  • Vue:用$attrs透传所有原生Props,defineProps仅声明业务自定义Props,inheritAttrs: false避免属性污染;
  • React:用Omit剔除业务Props,剩余Props通过{...restProps}透传,区分“业务逻辑Props”和“原生组件Props”。

4.2. 扩展点设计

插槽/Children优先

  • Vue:预留具名插槽(如Dialog的footer、Table的action),支持局部替换;
  • React:通过children和自定义插槽对象(如slots)实现扩展,避免硬编码。

4.3. 状态管理

内部隔离,外部可控

  • 组件内部维护基础状态(如分页的current/pageSize),外部通过Props覆盖默认值;
  • 事件透传:内部处理基础逻辑后,通过emit/回调将结果暴露给外部。

4.4. 样式封装

有默认样式+可覆盖

  • Vue:用scoped+:deep()穿透样式,预留CSS变量(如--el-dialog-width)支持外部定制;
  • React:用CSS Modules隔离样式,支持传递className覆盖默认样式。

4.5. 边界处理

需要有兜底与兼容

  • 对空数据、空列配置做兜底(如Table无数据时显示“暂无数据”);
  • 兼容原生组件的所有事件(如Dialog的close、Table的onChange)。

5. 封装的与团队规范

下面是一些封装的"度",与团队规范:

5.1. 避免过度封装

  • 不封装“一次性”组件:仅单个页面使用、无复用价值的逻辑无需封装;
  • 不滥用透传:核心业务Props显式声明,避免所有属性都透传导致维护困难。

5.2. 组件分层:基础组件 vs 业务组件

类型 示例 特点
基础组件 BaseDialog、BaseTable 基于Element UI/AntD封装,全项目复用
业务组件 OrderTable、UserForm 绑定具体业务逻辑,仅业务模块复用

5.3. 文档化:标注透传能力

封装组件需注明“支持透传XX原生组件的所有Props/事件”,示例:

/**
 * BaseTable 基于AntD Table的二次封装
 * @param {BaseTableProps} props - 组件属性
 * @param {PaginationProps} props.paginationConfig - 分页配置(业务自定义)
 * @param {Object} props.actionColumn - 操作列配置(业务自定义)
 * @param {TableProps} ...restProps - 透传AntD Table的所有原生Props(除pagination)
 */

6. 总结

基于Element UI/AntD的二次封装,核心是“保留原生能力+新增业务逻辑”——通过透传Props确保不丢失组件库的原生功能,通过自定义Props和插槽实现业务定制,最终达到“复用、统一、易扩展”的目标。

Vue中通过$attrsinheritAttrs: false实现透传,React中通过剩余参数{...restProps}区分业务与原生Props,两者核心思路一致:让封装后的组件既满足业务需求,又保持原生组件的灵活性。

好的二次封装组件,应该是“对开发者友好”的——调用方无需关心内部实现,只需通过简单的Props配置即可完成业务需求,同时能灵活扩展原生能力,真正做到封装不封死,以上。


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

浅谈 AI 搜索前端打字机效果的实现方案演进

作者:vivo 互联网前端团队 - He Yanjun

在当代前端开发领域,打字机效果作为一种极具创造力与吸引力的交互元素,被广泛运用于各类网站和应用程序中,为用户带来独特的视觉体验和信息呈现方式,深受广大用户的喜爱。

本文将深入介绍在AI搜索输出响应的过程中,打字机效果是怎样逐步演进的。力求以通俗的语言和严谨的思路深入剖析打字机效果在不同阶段的关键技术难点和优劣势。

1分钟看图掌握核心观点👇

图片

一、前言

在如今基于AI搜索的对话舞台上,如果一段文字像老式打字机一样逐字逐句展现在屏幕上,那将是一种具有独特魅力的吸引力。

话不多说,先来看下最终的实现效果。

图片

二、引言

在AI搜索场景中,由于大模型基于流式输出文本,需要多次响应结果到前端,因此这种场景十分适合使用打字机效果。

打字机效果是指在生成内容的场景中,文字逐字符动态显示,模拟人工打字的过程,主要是出于提升用户体验、优化交互逻辑和增强心理感知等方面的考量:

缓解等待焦虑,降低“无反馈”的负面体验。

内容是逐步响应的,打字机效果可以很好地提供“实时反馈”,用户可以感知到系统正在工作,从而减少了等待过程中的不确定性和焦虑感。

模拟自然交互,增强“类人对话”的沉浸感。

对话交流具有停顿、强调等节奏感,通过实时打字的模拟,跟容易拉近与用户的心理距离,增强对话感和沉浸感。

优化信息接收效率,避免“信息过载”。

如果一次性展示大量密密麻麻的文字,用户需要花时间筛选重点,容易产生抵触,通过打字机效果可以缓和阅读节奏,减少视觉和认知负担。

强化“AI生成”的感知,降低对“标准答案”的预期。

使用户感知到是AI实时计算结果,而非预存的标准答案,有助于用户理性客观地使用工具。

三、早期实现方案——纯文本逐字符打字效果

最开始的产品功能,需要根据用户输入的搜索词,流式输出并逐字符展示到页面上,这可以说是打字机效果的入门级实现了,不依赖任何复杂的技术,其流程图大致如下所示。

图片

3.1 详细说明

前端会定义一个字段用来缓存全量的markdown文本,每次服务端流式响应markdown文本到前端时,前端都会将其追加到这个缓存字段后,然后基于marked依赖库将全量的markdown文本转换为html片段。

要实现逐字符渲染的动画效果,就需要定时更新文本。定时功能一般采用setTimeout或setInterval来实现,而更新文本可以考虑innerHTML和appendChild的方式,这里采用的innerHTML方式插入文本,核心代码如下所示。

let fullText = 'test text';// 全量的html文本
let index = 0;// 当前打印到的下标
let timer = window.setInterval(() => {
  ++index;
  $dom.innerHTML = fullText.substring(0, index);
}, 40);

3.2 innerHTML与appendChild的核心区别对比

图片

为什么选择innerHTML而非appendChild?

由于服务端是流式返回markdown文本,因此每次返回的markdown文本可能不是完整的。

举个例子如下。

先返回下面一段markdown文本

** 这是一个
再返回下面一段markdown文本

标题 **
先返回的文本会当作纯文本展示,再返回的文本会与先返回的文本结合生成html片段如下

<strong>这是一个标题</strong>

如果使用appendChild的话,就不好处理上述场景。

3.3 小结

这种方式的优点就是简单易懂,很容易上手实现,也没有任何依赖。

但是,它的缺点也是显而易见的。比如,我们无法方便的添加一些额外的动画效果来增强视觉体验,如光标闪烁效果;对于一些复杂文本内容,或者需要更加灵活地控制展示细节时也会显得捉襟见肘;并且每次通过innerHTML渲染文本时,都触发了dom的销毁与创建,性能消耗大。

四、需求难度进一步提升

随着产品的迭代,业务要求打字内容不仅是纯文本,还需要穿插展示卡片等复杂样式效果,如下图所示。

卡片的类型包括应用、股票、影视等,需要可扩展、可配置,并且还会包括复杂的交互效果,如点击、跳转等。

图片

很明显,基于早期的实现方案已经远远不能满足日益增强的业务诉求了,必须考虑更加灵活高效的技术方案。

五、现代框架下的实现——基于Vue虚拟dom动态更新

通过上述的分析,打字内容中要穿插展示卡片,显然需要使用单例模式,否则如果每次打字都重新创建元素的话,不仅性能低下,而且数据和状态还无法保持一致。

而要使用单例模式,就必须根据现有数据对已插入节点进行插入、更新、移除等操作以保持数据的一致性,这就很自然地会想到使用现代前端框架来对打字机效果进行改进。

Vue是基于虚拟dom的渐进式javascript框架,仅在数据变化时计算差异并更新必要的部分,因此可以借助其数据驱动开发、组件化开发等特性,轻松地构建一个可复用的打字机效果组件。

5.1 设计思路

要实现打字正文中穿插卡片的效果,首先需要定义好返回的数据结构,它需要具备可扩展,方便解析,兼容markdown等特性,所以使用html标签是一种比较合适的方式,例如要展示一个应用卡片,可以下发如下所示数据。

<app id="" />

从下发的数据中可以获取到标签名和属性键值对,这样就可以通过标签名来渲染关联到的组件模板,通过属性键值对去服务端加载对应的数据,于是就可以水到渠成的把应用卡片展示出来,其流程图如下图所示。

图片

5.2 详细说明

组件模板文件按照一定规则组织在特定的目录下,在构建时打包到资源里,关键代码如下所示。

privateinit(){  
    let fileList = require.context('@/components/common/box', true, /\.vue$/);  
    fileList.keys().forEach((filePath) => {  
        let startIndex = filePath.lastIndexOf('/');  
        let endIndex = filePath.lastIndexOf('.');  
        let tagName = filePath.substring(startIndex + 1, endIndex);  
        this.widgetMap[tagName] = fileList(filePath).default;  
    });  
}

之前版本在每次接收到服务端下发的markdown文本时,都会做一次转换成html的操作,如果多次响应之间的间隔时间很短,则会出现略微卡顿的现象,因此这里转换为html时再增加一个防抖功能,可以很有效的避免卡顿。

每次定时截取到待渲染的html文本以后,会基于ultrahtml库将其转换为dom树,并过滤掉注释、脚本等标签,核心代码如下。

let toRenderHtml = this.rawHtml.substring(0, this.curIndex);  
let dom = {  
    type: ELEMENT_NODE,  
    name: 'p',  
    children: parse(toRenderHtml).children  
};

最后就是全局注册一个递归组件用来渲染转换后的dom树,核心代码如下。

render(h: any) {  
    // 此处省略若干代码

    // 处理子节点
    let children = this.dom['children'] || [];  
    let renderChildren = children.map((child: any, index: number) => { 
        return h(CommonDisplay, {  
            props: {  
                dom: child,  
                displayCursor: this.displayCursor,  
                lastLine: this.lastLine && index === children.length - 1,  
                ignoreBoxTag: this.ignoreBoxTag  
            }  
        });  
    });
  
    // 此处省略若干代码

    // 处理文本节点
    if (this.dom['type'] === TEXT_NODE) {  
        returnthis.renderTextNode({h, element: this.dom});  
    }

    // 处理自定义组件标签
    let tagName = this.dom['type'] === ELEMENT_NODE ? this.dom['name'] : 'div';  
    if (this.$factory.hasTag(tagName)) {  
        // 此处省略若干代码
        let widget = this.$factory.getWidget(tagName);
        return h(widget, {  
            key: tagId,  
            props: {  
                displayCursor: this.displayCursor,  
                lastLine: this.lastLine,  
                text,  
                ...attributes  
            }  
        }, isLastLeaf && this.displayCursor ? [h(commonCursor)] : []);
    }

    // 处理html原始标签
    return h(tagName, {  
        attrs: {  
            displayCursor: this.displayCursor,  
            lastLine: this.lastLine,  
            ...this.dom['attributes']  
        }  
    }, renderChildren);  
}

5.3 问题整理和解决

打字机功能终于正常运行了,流畅度还是不错的,但是在体验的过程中,也发现了一些细节问题

①打字文本中如果存在标签,如

xxx

,会出现先展示 < ,再展示 <p ,最后展示空的效果,也就是字符回退,极大影响阅读体验。

原因分析

定时截取待渲染文本时是通过定义一个下标递增逐字符截取的,这就导致标签并没有作为一个原子结构被整体截取,于是就出现了字符回退的现象。

解决方案

当下标指向的字符为 < 时,则往后截取到 > 的位置,核心代码如下。

if (curChar === '<') {  
    let lastGtIndex = this.rawHtml.indexOf('>', this.curIndex);
    if (lastGtIndex > -1) {
        this.curIndex = lastGtIndex + 1;
        returnfalse;
    }
}

② 打字文本中如果存在转义字符,如 " ,则会依次出现这些字符,最后再展示 " ,也就是字符闪烁,也十分影响阅读体验。

原因分析

原因同上述字符回退一样,也是没有把转义字符当作一个整体截取。

解决方案

当下标指向的字符为 & 时,则往后截取到转义字符结束的位置,核心代码如下。

// 大模型大概率只下发有限类别的转义字符,做成配置动态下发,不仅解析方便,定制下发也很及时  
if (curChar === '&') {  
    let matchEscape = this.config['writer']['escapeArr'].find((item: any) => {  
        returnthis.rawHtml.indexOf(item, this.curIndex) === this.curIndex;  
    });  
    if (matchEscape) {  
        this.curIndex += matchEscape.length;  
        returnfalse;  
    }  
}

③ 打字过程中的速度是固定的,缺少一点抑扬顿挫的节奏感,不够自然。

原因分析

定时器的间隔时间是固定的一个数值,所以表现为一个固定不变的打字节奏。

解决方案

可以根据未打印字符数来动态调整每次打字的速度,一种可选的实现方案如下。

假设未打印字符数为 N ,速度平滑指数为 a ,实际打字速度为 Vcurrent ,逻辑应达到的打字速度为 Vnew 。

if N <= 10 , Vnew = 100 ms / 1字符

if 10 < N <= 20 , Vnew = 100 - 8 * ( N - 10 ) ms / 1字符

if 20 < N , Vnew = 20 ms / 4字符

Vcurrent = a * Vcurrent + ( 1 - a ) * Vnew

上述策略可能会比较多,而且上线以后还有可能更换数值对照效果,因此为了支持配置化,我们可以对Vnew进行表达式归纳,如下所示。

Vnew = Vinit - w * ( N - min ) + b

Vinit 为默认初始打字速度,w 为每条策略的权重值,N 为未打印字符数,min 为每条策略的最小字符数量比较值,b 为每条策略的偏置。关键代码如下所示。

privatespeedFn({curSpeed, curIndex, totalLength}: any){  
    let leftCharLength = Math.max(0, totalLength - curIndex);  
    let matchStrategy = this.config['writer']['strategy'].find((item: any) => {  
        return (!item['min'] || item['min'] < leftCharLength)  
            && (!item['max'] || item['max'] >= leftCharLength);  
    });  
    let speed = this.config['writer']['initSpeed'] - matchStrategy['w'] * (leftCharLength - (matchStrategy['min'] || 0)) + matchStrategy['b'];  
    returnthis.config['writer']['smoothParam'] * curSpeed + (1 - this.config['writer']['smoothParam']) * speed;  
}

④ 打字过程中,会时不时的回退到之前字符的位置重新开始打字,例如当前展示 a = b + c ,等到下一次渲染时会从 a 开始重新打完这一段。

原因分析

由于markdown文本结合会生成html标签,从而导致字符数量增多,那么当前下标指向的字符就相对之前落后了。

let curIndex = 5;// 当前下标
let prevMarkdown = '**hello';// 上一次打印时的全量markdown文本
let prevHtml = '<p>**hello</p>';// 上一次打印时的全量html片段
let prevRenderHtml = '<p>**<p>';// 上一次打印到页面上的html片段
// 页面上会渲染 **

// 当服务端继续下发了 ** 的markdown文本时,curIndex会递增1变为6
let curMarkdown = '**hello**';// 当前打印时的全量markdown文本
let curHtml = '<p><strong>hello</strong></p>';// 当前打印时的全量html片段
let curRenderHtml = '<p><strong></strong><p>';// 当前打印到页面上的html片段
// 页面上会渲染空标签,然后重新开始打字,尤其是在数学公式场景中非常容易复现

解决方案

解决这个问题,需要分两步走。

首先需要判断打印到页面上的html片段是否有变化,因为只有变化时才会出现这种情况,而判断是否有变化只需要记录一下上一次的html片段和这一次的html片段是否不同即可,比较好处理。

其次就是需要重新定位下标到上一次打印到的位置,这里相对比较难处理,因为html的结构和内容都在变化,很难准确的定位到下标应该移动到什么位置。虽然我们不能准确定位,但是只要能够使当前打印到页面上的字符比上一次的字符多,就可以满足诉求了。于是我想到了textContent这个属性,它可以获取当前节点及其后代的所有文本内容。那么问题就转化为:找到一个下标,使得当前截取的html片段的textContent长度要比上一次的textContent长度大。

综上所述,可以得到核心代码如下所示。

if (this.isHtmlChanged()) {  
    let domRange: any = document.createRange();  
    let prevFrag = domRange.createContextualFragment(this.prevRenderHtml);  
    let prevTextContent = prevFrag.textContent;  
    let diffNum = 1;  
    do {  
        this.curIndex += diffNum;  
        let curHtml = this.rawHtml.substring(0, this.curIndex);  
        let curFrag = domRange.createContextualFragment(curHtml);  
        let curTextContent = curFrag.textContent;  
        diffNum = prevTextContent.length - curTextContent.length;  
        if (diffNum <= 0) {  
            break;  
        }  
    } while (this.curIndex < this.rawHtml.length);  
}

5.4 小结

通过现代前端框架构建打字机组件,不仅减少了不必要的渲染和性能消耗,而且还能高效灵活的穿插各种酷炫的样式效果,实现更多复杂的产品功能。

六、未来展望

本文详细介绍了AI搜索中前端打字机效果的实现方案演进过程,从最初的纯文本逐字符打字效果,到借助现代前端框架实现灵活可复用的打字机组件,每一个技术难点的技术突破无不体现了前端技术的持续进步和产品不断追求卓越的态度。同时我也希望本文可以抛砖引玉,为读者打开思路,提供借鉴。

随着人工智能和前端技术的不断发展和创新生态的日益完善,未来一定会不断涌现大量的新技术和新理念。我相信只要时刻保持积极学习和不断尝试的探索精神,就能开拓出更多精彩创新的实现方案和应用场景。

❌