阅读视图

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

vue甘特图vxe-gantt如何实现拖拽任务条时如有已关联依赖线,同时更新依赖任务的日期的方式

vue甘特图vxe-gantt如何实现拖拽任务条时如有已关联依赖线,同时更新依赖任务的日期的方式

当任务关联前置任务或后置任务依赖线时,拖拽该任务时同步更新对应的起始日期和结束日期,可以通过 task-bar-drag-config.moveSetMethod 来自定义业务逻辑

extend_gantt_chart_gantt_dependency_move_update

基础代码

简单实现同步移动任务

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive } from 'vue'
import { VxeGanttDependencyType } from 'vxe-gantt'
import XEUtils from 'xe-utils'

const ganttOptions = reactive({
  border: true,
  height: 600,
  rowConfig: {
    keyField: 'id' // 行主键
  },
  taskBarConfig: {
    showProgress: true, // 是否显示进度条
    showContent: true, // 是否在任务条显示内容
    moveable: true, // 是否允许拖拽任务移动日期
    resizable: true, // 是否允许拖拽任务调整日期
    linkCreatable: true, // 是否允许自定义创建依赖线
    barStyle: {
      round: true, // 圆角
      bgColor: '#fca60b', // 任务条的背景颜色
      completedBgColor: '#65c16f' // 已完成部分任务条的背景颜色
    }
  },
  taskLinkConfig: {
    isHover: true, // 当鼠标移到依赖线时,是否要高亮当前依赖线
    isCurrent: true, // 当鼠标点击依赖线时,是否要高亮当前依赖线
    isDblclickToRemove: true // 是否允许双击依赖线删除
  },
  taskViewConfig: {
    tableStyle: {
      width: 480 // 表格宽度
    }
  },
  taskBarMoveConfig: {
    // 自定义拖拽结束时任务日期被赋值的方法
    async moveSetMethod ({ row, startValue, endValue, offsetSize, linkInfo }) {
      const { toRows, fromRows } = linkInfo
      row.start = startValue
      row.end = endValue
      // 实现拖拽任务后,关联任务自动同步更新日期
      fromRows.forEach(row => {
        row.start = XEUtils.toDateString(XEUtils.getWhatDay(row.start, offsetSize), 'yyyy-MM-dd')
        row.end = XEUtils.toDateString(XEUtils.getWhatDay(row.end, offsetSize), 'yyyy-MM-dd')
      })
      toRows.forEach(row => {
        row.start = XEUtils.toDateString(XEUtils.getWhatDay(row.start, offsetSize), 'yyyy-MM-dd')
        row.end = XEUtils.toDateString(XEUtils.getWhatDay(row.end, offsetSize), 'yyyy-MM-dd')
      })
    }
  },
  links: [
    { from: 10001, to: 10002, type: VxeGanttDependencyType.FinishToFinish },
    { from: 10004, to: 10005, type: VxeGanttDependencyType.StartToStart },
    { from: 10005, to: 10006, type: VxeGanttDependencyType.FinishToStart },
    { from: 10013, to: 10012, type: VxeGanttDependencyType.StartToFinish }
  ],
  columns: [
    { type: 'seq', width: 70 },
    { field: 'title', title: '任务名称' },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 },
    { field: 'progress', title: '进度(%)', width: 80 }
  ],
  data: [
    { id: 10001, title: '任务1', start: '2024-03-01', end: '2024-03-04', progress: 3 },
    { id: 10002, title: '任务2', start: '2024-03-03', end: '2024-03-08', progress: 10 },
    { id: 10003, title: '任务3', start: '2024-03-03', end: '2024-03-11', progress: 90 },
    { id: 10004, title: '任务4', start: '2024-03-05', end: '2024-03-11', progress: 15 },
    { id: 10005, title: '任务5', start: '2024-03-08', end: '2024-03-15', progress: 100 },
    { id: 10006, title: '任务6', start: '2024-03-10', end: '2024-03-21', progress: 5 },
    { id: 10007, title: '任务7', start: '2024-03-15', end: '2024-03-24', progress: 70 },
    { id: 10008, title: '任务8', start: '2024-03-05', end: '2024-03-15', progress: 50 },
    { id: 10009, title: '任务9', start: '2024-03-19', end: '2024-03-20', progress: 5 },
    { id: 10010, title: '任务10', start: '2024-03-12', end: '2024-03-20', progress: 10 },
    { id: 10011, title: '任务11', start: '2024-03-01', end: '2024-03-08', progress: 90 },
    { id: 10012, title: '任务12', start: '2024-03-03', end: '2024-03-06', progress: 60 },
    { id: 10013, title: '任务13', start: '2024-03-02', end: '2024-03-05', progress: 50 },
    { id: 10014, title: '任务14', start: '2024-03-04', end: '2024-03-15', progress: 0 },
    { id: 10015, title: '任务15', start: '2024-03-01', end: '2024-03-05', progress: 30 }
  ]
})
</script>

还可以实现更复杂的逻辑

<template>
  <div>
    <vxe-gantt ref="ganttRef" v-bind="ganttOptions" @task-bar-drag-end="onTaskDragEnd"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive, ref } from 'vue'
import { VxeGanttDependencyType } from 'vxe-gantt'
import XEUtils from 'xe-utils'

const ganttRef = ref(null)

const ganttOptions = reactive({
  // ...(保持原有配置)
  taskBarMoveConfig: {
    // 自定义拖拽结束时任务日期被赋值的方法
    async moveSetMethod({ row, startValue, endValue, offsetSize, linkInfo }) {
      const { toRows, fromRows, toLinks, fromLinks } = linkInfo
    
      // 1. 获取变更前的日期(用于错误恢复)
      // const oldStart = row.start
      // const oldEnd = row.end

      // 2. 更新当前拖拽任务的日期
      row.start = startValue
      row.end = endValue


      // 4. 统一更新依赖任务的方法
      //   根据依赖类型计算出新的日期,而不是简单粗暴地整体偏移
      const updateDependentTasks = (tasks, currentRow, linkType, offsetDays) => {
        tasks.forEach(task => {
          const currentStart = new Date(currentRow.start)
          const currentEnd = new Date(currentRow.end)
          let newStart, newEnd

          // 根据不同的依赖类型,更新关联任务的日期
          switch (linkType) {
            case VxeGanttDependencyType.FinishToStart:
              // 前置任务结束后,后置任务才能开始
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.end, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            case VxeGanttDependencyType.StartToStart:
              // 两个任务同时开始
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            case VxeGanttDependencyType.FinishToFinish:
              // 两个任务同时完成
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(task.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            case VxeGanttDependencyType.StartToFinish:
              // 当前任务完成才能开始
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(currentRow.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
              break
            default:
              // 默认整体偏移
              newStart = XEUtils.toDateString(
                XEUtils.getWhatDay(task.start, offsetDays),
                'yyyy-MM-dd'
              )
              newEnd = XEUtils.toDateString(
                XEUtils.getWhatDay(task.end, offsetDays),
                'yyyy-MM-dd'
              )
          }

          // 更新任务数据
          task.start = newStart || task.start
          task.end = newEnd || task.end
        })
      }

      // 6. 更新依赖任务(前置和后置)
      // 使用依赖关系信息来精确更新
      if (fromRows.length && fromLinks.length) {
         updateDependentTasks([fromRows[0]], row, fromLinks[0], offsetSize)
      }

      if (toRows.length && toLinks.length) {
        updateDependentTasks([toRows[0]], row, toLinks[0], offsetSize)
      }

    }
  },
  // ...(保持原有数据配置)
})

// 监听拖拽事件,执行其他业务逻辑(如保存到后端)
const onTaskDragEnd = ({ row, startValue, endValue }) => {
  // 可以在这里保存更新后的任务数据,或者执行其他业务逻辑
  console.log('任务拖拽完成', { row, startValue, endValue })
  
  // 可选:触发数据更新到后端
  // updateTaskToBackend(row)
}
</script>

依赖类型说明

依赖类型 原逻辑缺陷 优化后逻辑
FinishToStart 前置任务结束日期推迟,后置任务应该整体推迟?❌ 应该是后置任务的开始日期=前置任务的结束日期 仅更新后置任务的开始日期,保持持续时间不变
StartToStart 同时开始的任务,拖拽一个不应该导致另一个结束日期整体偏移 保持两个任务的结束日期相对不变,仅调整开始日期
FinishToFinish 两个任务同时结束,拖拽一个不应该影响另一个的开始日期 保持两个任务的开始日期相对不变,仅调整结束日期
StartToFinish 前置任务开始日期影响后置任务完成日期 根据依赖关系的具体约束来精确更新

gantt.vxeui.com

Vue 响应式系统源码级剖析:从 Object.defineProperty 到 Proxy

Vue 3 的响应式系统被誉为前端框架的"艺术品"。它如何在数据变化时精准触发视图更新?如何避免不必要的重渲染?

今天,我们不讲表面用法,直接从 V8 引擎的内存布局出发,深度剖析 Vue 响应式系统的底层实现机制。

1. 响应式系统的核心目标

响应式系统的本质是建立一个依赖追踪图(Dependency Graph)

数据变化 → 触发 Getter → 收集依赖 → 执行 Setter → 通知更新 → 视图刷新

难点在于:

  1. 精准收集:只收集真正用到该数据的组件
  2. 高效通知:避免无关组件的重复渲染
  3. 嵌套支持:深层对象、数组的响应式处理

2. Vue 2 方案:Object.defineProperty 的局限

2.1 核心实现

function defineReactive(obj, key, val) {
    const dep = new Dep(); // 依赖收集器
    
    Object.defineProperty(obj, key, {
        get() {
            if (Dep.target) {
                dep.depend(); // 收集当前 Watcher
            }
            return val;
        },
        set(newVal) {
            if (newVal === val) return;
            val = newVal;
            dep.notify(); // 通知所有依赖更新
        }
    });
}

2.2 致命缺陷

问题 原因 影响
无法检测属性新增/删除 Object.defineProperty 只能劫持已存在的属性 需要用 Vue.set
数组变异方法失效 数组索引赋值不会触发 Setter 需要重写 7 个数组方法
递归遍历性能差 初始化时需要深度遍历整个对象树 大型对象卡顿

3. Vue 3 方案:Proxy 的降维打击

3.1 核心实现

function reactive(target) {
    return new Proxy(target, {
        get(target, key, receiver) {
            const res = Reflect.get(target, key, receiver);
            track(target, key); // 收集依赖
            return isObject(res) ? reactive(res) : res;
        },
        set(target, key, value, receiver) {
            const oldValue = target[key];
            const result = Reflect.set(target, key, value, receiver);
            if (oldValue !== value) {
                trigger(target, key); // 触发更新
            }
            return result;
        }
    });
}

3.2 Proxy 的优势

特性 Object.defineProperty Proxy
拦截范围 单个属性 整个对象
新增/删除属性 不支持 ✅ 原生支持
数组索引操作 ❌ 需重写方法 ✅ 原生支持
性能 递归遍历 O(n) 惰性代理 O(1)

4. 依赖收集机制:Dep 与 Watcher 的协作

4.1 Dep(依赖收集器)

class Dep {
    constructor() {
        this.subscribers = new Set(); // 使用 Set 去重
    }
    
    depend() {
        if (Dep.target) {
            this.subscribers.add(Dep.target);
        }
    }
    
    notify() {
        this.subscribers.forEach(watcher => {
            watcher.update();
        });
    }
}

4.2 WeakMap 存储映射

Vue 3 使用 WeakMap 建立数据到依赖的映射:

const targetMap = new WeakMap();

function track(target, key) {
    if (!Dep.target) return;
    
    let depsMap = targetMap.get(target);
    if (!depsMap) {
        depsMap = new Map();
        targetMap.set(target, depsMap);
    }
    
    let dep = depsMap.get(key);
    if (!dep) {
        dep = new Set();
        depsMap.set(key, dep);
    }
    
    dep.add(Dep.target);
}

数据结构

targetMap (WeakMap)
  └─ target (对象)
      └─ depsMap (Map)
          └─ key (属性名)
              └─ dep (Set)
                  └─ effect (副作用函数)

5. 调度系统:异步更新队列

Vue 不会在数据变化时立即更新视图,而是使用异步批处理

const queue = [];
let pending = false;

function queueJob(job) {
    if (!queue.includes(job)) {
        queue.push(job);
    }
    
    if (!pending) {
        pending = true;
        nextTick(flushJobs);
    }
}

function flushJobs() {
    queue.sort((a, b) => a.id - b.id); // 按优先级排序
    
    for (const job of queue) {
        job();
    }
    
    queue.length = 0;
    pending = false;
}

优势

  • 多次数据变化只触发一次渲染
  • 避免中间状态导致的闪烁
  • 按优先级排序,确保父子组件更新顺序

6. 计算属性与侦听器:衍生状态处理

6.1 Computed(惰性求值)

function computed(getter) {
    let value;
    let dirty = true;
    
    const runner = effect(getter, {
        scheduler: () => {
            dirty = true; // 标记为脏数据
        }
    });
    
    return {
        get value() {
            if (dirty) {
                value = runner();
                dirty = false;
            }
            return value;
        }
    };
}

核心机制

  • 只有在访问时才计算(惰性)
  • 依赖变化时标记 dirty,下次访问重新计算
  • 避免不必要的重复计算

6.2 Watch(主动侦听)

function watch(source, callback) {
    const getter = () => traverse(source);
    
    effect(getter, {
        scheduler: () => {
            callback(getter());
        }
    });
}

7. 工业界实战:性能优化技巧

7.1 markRaw(跳过响应式)

const rawObj = markRaw({ /* 大型数据 */ });

不需要响应式的对象(如图表实例、第三方库对象),用 markRaw 标记,避免 Proxy 开销。

7.2 shallowReactive(浅层响应式)

const state = shallowReactive({
    nested: { deep: { value: 1 } }
});

只代理第一层,嵌套对象保持原始引用,减少内存占用。

7.3 冻结对象优化

const constant = Object.freeze({ /* 常量配置 */ });

Vue 会自动跳过已冻结的对象,不会进行响应式转换。

8. 面试考点

Q1: Vue 2 为什么无法检测对象属性的新增?

A: Object.defineProperty 只能劫持对象上已存在的属性。新增属性时没有 Getter/Setter,需要在初始化时递归遍历所有属性,动态新增的属性无法被劫持。

Q2: Proxy 为什么比 Object.defineProperty 性能好?

A: Proxy 是惰性代理,只有在访问属性时才递归代理子对象。而 Object.defineProperty 在初始化时需要完整遍历整个对象树,时间复杂度 O(n)。

Q3: Vue 3 的依赖收集用了什么数据结构?

A: 使用 WeakMap → Map → Set 三层映射。targetMap(WeakMap)存储目标对象,depsMap(Map)存储属性名,dep(Set)存储副作用函数。使用 Set 自动去重。

9. 总结

Vue 响应式系统的核心设计:

  1. 数据劫持:Proxy 拦截属性访问
  2. 依赖收集:WeakMap 建立映射关系
  3. 副作用调度:异步队列批量更新
  4. 惰性求值:Computed 避免重复计算

这套系统不仅是 Vue 的核心,更是响应式编程范式的经典实现。理解它,你就掌握了现代前端框架的精髓。


💡 提示:  完整源码解析(含 Dep/Watcher 实现)已开源到 GitHub。

如果你觉得这篇关于"Vue 底层原理"的文章对你有帮助,欢迎点赞收藏!

Vite4.x+打包优化实战指南(无冗余):从体积到速度,一文吃透所有技巧

Vite凭借ESBuild预构建与原生ESM支持,天生具备高性能优势,开发环境下的秒级启动、极速热更新体验深受前端开发者青睐。但随着项目规模扩大、第三方依赖增多,极易出现打包体积臃肿、构建耗时增加、首屏加载延迟等问题。不同于Webpack的构建逻辑,Vite的打包优化需围绕其“开发环境ESBuild、生产环境Rollup”的双引擎架构展开,核心目标是「精简产物体积、提升构建速度、优化加载性能」。以下是全维度实操优化方案,适配Vite4.x及以上版本,所有配置均可直接复制到项目中落地,无需额外修改。

一、前置:精准定位打包瓶颈(避免盲目优化)

优化前需先通过工具定位核心问题(如超大体积依赖、冗余资源、构建耗时瓶颈),避免盲目配置造成无效消耗。推荐2个零成本排查工具,快速锁定优化重点,提升优化效率。

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

该插件可可视化展示打包后各文件、第三方依赖的体积占比,能精准定位体积过大的模块,是精简打包体积的核心工具,新手也能快速上手。

# 安装依赖(仅开发环境需安装)
npm install rollup-plugin-visualizer -D
# 或使用yarn安装
yarn add rollup-plugin-visualizer -D
// vite.config.js 核心配置(直接复制可用)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    vue(),
    // 打包体积可视化配置
    visualizer({
      open: true, // 打包完成后自动打开可视化分析页面
      gzipSize: true, // 显示gzip压缩后的体积(更贴近生产环境实际体积)
      brotliSize: true, // 显示brotli压缩后的体积(压缩率更高,参考价值更大)
      filename: 'stats.html' // 生成的分析文件名称,默认存放在项目根目录
    })
  ]
})

执行npm run build命令后,项目根目录会自动生成stats.html文件,打开该文件即可清晰查看各依赖、组件的体积占比。建议重点关注体积超过100KB的模块,优先进行优化,性价比最高。

2. 构建速度分析(--profile参数)

借助Vite自带的--profile参数,可生成Rollup构建性能分析报告,精准定位构建过程中耗时最长的环节(如依赖处理、资源压缩、插件执行等),针对性优化更高效。

# 在package.json中添加构建速度分析脚本
"scripts": {
  "build:profile": "vite build --profile" // 生成性能分析报告
}

# 执行命令,生成profile-xxx.json格式的分析报告
npm run build:profile

注意:原文档中推荐的Rollup Analyzer网页(rollupjs.org/analyzer/)目…

二、核心优化:减小打包体积(提升加载速度)

打包体积过大是导致首屏加载缓慢的主要原因,核心优化方向围绕「剔除冗余代码、压缩静态资源、合理分包拆分」展开,从源头精简产物体积,提升页面加载效率。

1. 基础配置优化(vite.config.js核心配置)

通过Vite的build配置,开启基础压缩、禁用无用功能,无需额外安装插件,即可快速减小打包体积,是所有Vite项目的必做优化,上手门槛极低。

export default defineConfig({
  build: {
    // 1. 禁用生产环境源码映射(大幅减小体积,上线无需调试源码,必做)
    sourcemap: false,
    // 2. 开启代码压缩(默认启用esbuild,速度比terser快10倍以上;追求极致体积可改用terser)
    minify: 'esbuild',
    // 3. 设置打包目标环境,移除无用语法(适配主流浏览器,避免冗余兼容代码)
    target: 'es2015',
    // 4. 静态资源优化:小于4kb的资源转为base64,减少HTTP请求次数
    assetsInlineLimit: 4096, // 单位:bytes,默认4kb,无需随意修改
    // 5. 规范静态资源输出目录,便于后续CDN配置和项目维护
    assetsDir: 'static/assets',
    // 6. 分包策略:拆分大型依赖,提升浏览器缓存命中率(核心优化)
    rollupOptions: {
      output: {
        // 手动分包:将第三方依赖拆分到单独chunk,避免主包过大
        manualChunks: {
          // 把vue相关核心依赖打包为一个chunk(不常更新,可长期缓存)
          vueVendor: ['vue', 'vue-router', 'pinia'],
          // 把工具类依赖打包为一个chunk
          utils: ['axios', 'lodash-es'],
          // 把UI库单独打包(如Element Plus、Ant Design Vue,体积较大)
          ui: ['element-plus']
        }
      }
    }
  }
})

关键说明:manualChunks分包策略可根据项目实际依赖灵活调整,核心逻辑是将“不常更新的第三方依赖”与“频繁迭代的业务代码”拆分。这样用户二次访问时,可直接从浏览器缓存中读取第三方依赖chunk,无需重新下载,大幅提升加载速度。

2. 静态资源优化(图片、字体、CSS)

静态资源(尤其是图片)通常占打包体积的60%以上,是体积优化的重点。优化核心的是「压缩体积、优化格式、合理缓存」,兼顾加载速度和视觉体验。

(1)图片优化(vite-plugin-imagemin)

该插件可自动压缩图片体积,支持WebP、Avif等现代图片格式,在不影响视觉效果的前提下,可将图片体积缩减30%-50%,适配所有主流项目。

# 安装图片压缩插件(仅开发环境需安装)
npm install vite-plugin-imagemin -D
// vite.config.js 配置(直接复制可用)
import viteImagemin from 'vite-plugin-imagemin'

export default defineConfig({
  plugins: [
    vue(),
    viteImagemin({
      // 不同图片格式的针对性压缩配置,平衡速度与体积
      gifsicle: { optimizationLevel: 3 }, // GIF压缩,等级1-33为最优压缩
      optipng: { optimizationLevel: 3 }, // PNG压缩,等级0-73平衡速度与体积
      mozjpeg: { quality: 80 }, // JPG压缩,质量70-9080为最佳视觉与体积平衡
      webp: { quality: 80 }, // WebP压缩,自动将JPG/PNG转为WebP格式
      avif: { quality: 80 } // Avif压缩,比WebP体积更小,兼容性略差(可选)
    })
  ]
})

(2)字体资源优化

字体文件通常体积较大,若全量打包会大幅增加产物体积,可通过“按需引入、格式转换、CDN引入”三种方式优化,兼顾性能与体验。

  • 按需引入:仅引入项目中实际使用的字体权重(如400、500)和字符(如中文仅引入常用3000个字符),剔除无用字符;
  • 格式转换:将TTF格式字体转为WOFF2格式,体积比TTF小40%以上,支持所有主流浏览器(IE除外);
  • CDN引入:将思源黑体、Roboto等常用字体通过CDN引入,避免打包到项目中,减少体积占用。

(3)CSS优化

核心目标是剔除未使用的CSS代码,减少样式文件体积,主要依赖unplugin-vue-components(自动按需引入组件样式)和purgecss(剔除全局无用CSS),配置后无需手动管理样式引入。

# 安装依赖(仅开发环境需安装)
npm install unplugin-vue-components purgecss-plugin-vite -D
// vite.config.js 配置(直接复制可用)
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import PurgeCSSPlugin from 'purgecss-plugin-vite'

export default defineConfig({
  plugins: [
    vue(),
    // 自动导入Vue API和组件,按需引入对应样式,避免全量引入
    AutoImport({
      resolvers: [ElementPlusResolver()],
      imports: ['vue', 'vue-router', 'pinia'] // 按需导入常用API
    }),
    Components({
      resolvers: [ElementPlusResolver()] // 自动按需引入UI组件及样式(以Element Plus为例)
    }),
    // 剔除未使用的CSS(仅生产环境生效,避免开发环境样式异常)
    PurgeCSSPlugin({
      content: ['./index.html', './src/**/*.vue'], // 扫描需要保留的CSS选择器
      variables: true, // 保留CSS变量,避免样式异常
      safelist: {
        standard: ['html', 'body'] // 强制保留的基础选择器,避免全局样式丢失
      }
    })
  ],
  // 禁用CSS源码映射(开发环境无需调试可关闭,减少体积)
  css: {
    devSourcemap: false
  }
})

3. 依赖优化(剔除冗余,减少打包体积)

第三方依赖是导致打包体积臃肿的主要原因之一,核心优化方向是「按需引入、轻量替代、CDN外链」,从源头减少冗余依赖,兼顾性能与开发效率。

(1)按需引入第三方依赖

对于Element Plus、Ant Design Vue、ECharts等大型第三方依赖,严禁全量引入,仅引入项目中实际使用的组件和API,可大幅减少冗余代码。

以Element Plus为例:配合上文CSS优化中的unplugin-vue-components插件,无需手动引入组件和样式,直接在组件中使用即可,打包时会自动剔除未使用的组件和样式,无需额外配置。

(2)轻量依赖替代

替换体积较大的依赖,用轻量级库实现相同功能,从源头减小打包体积,推荐以下常用替代方案(API基本一致,无需修改业务代码):

  • lodash → lodash-es(支持Tree-Shaking,可按需导入单个方法,避免全量打包);
  • moment.js → dayjs(体积仅2KB,比moment.js小80%+,API完全一致,无缝替换);
  • axios → ky(体积更小,支持Promise,API更简洁,适配现代项目);
  • echarts → chart.js(轻量级图表库,适合简单可视化场景,体积仅为echarts的1/3)。

(3)CDN外链引入公共依赖

将Vue、Vue Router、Pinia等不常更新的公共依赖,通过CDN外链引入,避免打包到项目中,可大幅减小主包体积,同时利用CDN的分布式节点提升加载速度。

注意:原文档中推荐的3个CDN链接(Vue、Vue Router、Pinia),其中Vue Router和Pinia的CDN文件存在字数超限问题,Vue的CDN文件可正常使用,以下优化配置可直接落地,同时规避链接异常问题。

// vite.config.js 配置(优化后,规避CDN链接异常)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { vitePluginForCDN } from 'vite-plugin-cdn-import'

export default defineConfig({
  plugins: [
    vue(),
    vitePluginForCDN({
      // 配置需要CDN引入的依赖(选用稳定可访问的CDN链接)
      modules: [
        {
          name: 'vue',
          var: 'Vue', // 全局变量名,需与CDN文件暴露的变量一致
          path: 'https://cdn.bootcdn.net/ajax/libs/vue/3.3.4/vue.global.prod.js' // 可正常访问
        },
        {
          name: 'vue-router',
          var: 'VueRouter',
          path: 'https://cdn.jsdelivr.net/npm/vue-router@4.2.5/dist/vue-router.global.prod.js' // 替代链接,稳定可访问
        },
        {
          name: 'pinia',
          var: 'Pinia',
          path: 'https://cdn.jsdelivr.net/npm/pinia@2.1.7/dist/pinia.iife.prod.js' // 替代链接,稳定可访问
        }
      ]
    })
  ],
  // 排除CDN引入的依赖,避免重复打包(必配,否则会出现重复引入问题)
  build: {
    rollupOptions: {
      external: ['vue', 'vue-router', 'pinia']
    }
  }
})

4. 开启Gzip/Brotli压缩(大幅减小体积)

通过插件生成Gzip、Brotli格式的压缩资源,配合Nginx服务器配置启用压缩,可将资源体积缩减60%-80%,是生产环境必做的优化,零开发成本,收益显著。

# 安装压缩插件(仅开发环境需安装)
npm install vite-plugin-compression -D
// vite.config.js 配置(直接复制可用)
import viteCompression from 'vite-plugin-compression'

export default defineConfig({
  plugins: [
    vue(),
    // 开启Gzip压缩(兼容性好,所有主流浏览器均支持,推荐优先启用)
    viteCompression({
      algorithm: 'gzip', // 压缩算法
      threshold: 10240, // 大于10KB的文件才压缩(避免小文件压缩后体积反而变大)
      deleteOriginFile: false // 不删除源文件,避免部署时出现资源缺失问题
    }),
    // 开启Brotli压缩(压缩率更高,优先使用,需服务器支持Brotli模块)
    viteCompression({
      algorithm: 'brotliCompress',
      threshold: 10240,
      deleteOriginFile: false
    })
  ]
})

补充:Nginx需配置对应压缩规则,才能让浏览器加载压缩后的资源,以下是生产环境通用配置示例,直接复制到Nginx配置文件即可:

server {
  # Gzip压缩配置(必配)
  gzip on; # 开启Gzip压缩
  gzip_types text/plain text/css application/javascript image/svg+xml; # 需压缩的资源类型
  gzip_min_length 10k; # 小于10KB的文件不压缩
  gzip_comp_level 6; # 压缩等级1-9,6为平衡速度与压缩率的最佳值

  # Brotli压缩配置(可选,需安装ngx_brotli模块)
  brotli on; # 开启Brotli压缩
  brotli_types text/plain text/css application/javascript image/svg+xml; # 需压缩的资源类型
  brotli_min_length 10k; # 小于10KB的文件不压缩
  brotli_comp_level 6; # 压缩等级1-11,6为最佳平衡值
}

三、进阶优化:提升打包速度(减少构建耗时)

对于大型项目(代码量10万行+、依赖较多),打包耗时过长会严重影响开发效率。核心优化方向是「优化依赖预构建、利用缓存机制、减少不必要的插件处理」,大幅缩短构建时间。

1. 优化依赖预构建(optimizeDeps配置)

依赖预构建是Vite提升启动和打包速度的核心机制,它会通过ESBuild将CommonJS/UMD格式的依赖转为ESM格式,避免浏览器处理复杂依赖树。通过optimizeDeps配置,可进一步提升预构建效率,解决部分依赖未被自动检测的问题。

export default defineConfig({
  // 依赖预构建优化(直接复制可用)
  optimizeDeps: {
    // 1. 强制预构建指定依赖(解决部分依赖未被Vite自动检测、预构建失败的问题)
    include: ['axios', 'echarts', 'lodash-es'],
    // 2. 排除无需预构建的依赖(本身就是ESM格式,避免重复构建,节省时间)
    exclude: ['vue', 'vue-router'],
    // 3. 自定义ESBuild选项,提升预构建速度,适配现代浏览器
    esbuildOptions: {
      target: 'es2020'
    }
  }
})

关键说明:Vite会将预构建结果缓存到node_modules/.vite目录,只有依赖变更或配置修改时才会重新构建。若遇到预构建异常,可删除该目录,重新执行打包命令,即可强制重新预构建。

2. 利用缓存机制(提升二次构建速度)

通过配置缓存目录,让Vite缓存构建结果,二次打包时可直接复用缓存,大幅减少构建耗时,尤其适合大型项目和频繁打包的场景,可将二次构建速度提升60%+。

export default defineConfig({
  // 自定义缓存目录(默认是node_modules/.vite,可自定义路径)
  cacheDir: './.vite_cache',
  // 启用文件系统缓存(开发环境和生产环境均生效,必配)
  server: {
    fsCache: true
  },
  // 生产环境构建缓存(Vite 4.0+ 支持,进一步提升生产打包速度)
  build: {
    cache: {
      type: 'filesystem' // 基于文件系统的缓存,稳定可靠
    }
  }
})

补充:Docker环境中部署项目时,可将缓存目录挂载为Volume,避免每次重建容器时丢失缓存,进一步提升构建效率,减少部署时间。

3. 插件优化(减少不必要的插件处理)

过多的插件会增加构建耗时,甚至出现插件冲突问题。优化核心是“按环境区分插件”,避免开发环境插件在生产环境生效,同时剔除无用插件,精简插件执行流程。

// 按环境区分插件,减少生产环境插件开销(直接复制可用)
export default defineConfig(({ mode }) => {
  const isProd = mode === 'production' // 判断当前环境是否为生产环境
  return {
    plugins: [
      vue(), // 所有环境都需要启用的核心插件
      // 生产环境才启用的插件(压缩、打包分析等,开发环境无需加载)
      ...(isProd ? [
        viteImagemin({ /* 图片压缩配置,参考上文 */ }),
        viteCompression({ /* 压缩配置,参考上文 */ }),
        visualizer({ /* 体积分析配置,参考上文 */ })
      ] : []),
      // 开发环境才启用的插件(热更新、调试等,生产环境无需加载)
      ...(isProd ? [] : [
        // 示例:开发环境调试插件(仅开发时使用,生产环境剔除)
        require('vite-plugin-debug').default()
      ])
    ]
  }
})

关键说明:部分插件可通过enforce: 'post'延迟执行,避免阻塞核心构建流程。例如图片压缩插件,可设置enforce: 'post',让其在代码打包完成后再处理图片,提升整体构建速度。

4. 并行化编译(利用多线程提升速度)

启用Rollup的多线程编译,充分利用CPU多核优势,提升代码转译和压缩速度,需Node.js v12及以上版本支持,大型项目收益显著。

# 安装多线程插件(仅开发环境需安装)
npm install @rollup/plugin-dynamic-import-vars -D
// vite.config.js 配置(直接复制可用)
import dynamicImportVariables from '@rollup/plugin-dynamic-import-vars'

export default defineConfig({
  plugins: [
    vue(),
    dynamicImportVariables({
      workers: true // 启用多线程编译,自动利用CPU多核资源
    })
  ]
})

四、避坑指南(避免优化失效或性能倒退)

  • 坑1:过度配置alias导致路径解析缓慢 解决方案:仅配置核心目录别名(如@对应src),避免配置过多无用别名,增加Vite路径解析开销,反而降低构建速度。
  • 坑2:assetsInlineLimit设置过小/过大 解决方案:默认4kb即可,无需随意修改。设置过小会增加HTTP请求次数,设置过大会导致JS/CSS文件体积暴增,反而影响首屏加载速度。
  • 坑3:CDN引入依赖后,项目报错“Vue is not defined” 解决方案:① 确保CDN资源引入顺序正确(先引入Vue,再引入Vue Router、Pinia等依赖);② 检查rollupOptions.external配置,确保配置的依赖名与CDN文件暴露的全局变量名一致。
  • 坑4:Tree-Shaking不生效,未使用的代码未被剔除 解决方案:① 确保项目package.json中添加"type": "module"(启用ESM模块规范);② 避免使用CommonJS语法(require),全部使用ES模块语法(import/export);③ 确保依赖本身支持Tree-Shaking(如优先使用lodash-es而非lodash)。
  • 坑5:Linux环境下Vite因ENOSPC错误崩溃 解决方案:项目文件过多超出系统文件监听器限制,执行命令sudo sysctl fs.inotify.max_user_watches=524288临时解决;若需永久生效,需修改/etc/sysctl.conf文件,添加对应配置并执行sudo sysctl -p生效。
  • 坑6:CDN链接异常导致项目加载失败 解决方案:若遇到CDN链接字数超限、无法访问的问题,可替换为.jsdelivr.net等稳定CDN源,如上文Vue Router、Pinia的CDN替代链接,确保资源可正常加载。
  • 坑7:Rollup Analyzer网页解析失败无法使用 解决方案:暂用替代方案,将build:profile生成的JSON报告导入rollup-plugin-visualizer生成的stats.html页面,或使用Chrome开发者工具的Performance面板分析构建耗时。

五、优化优先级建议(快速落地,高效提升)

无需一次性实施所有优化方案,建议优先落地“低成本、高收益”的方案,快速提升项目性能,再逐步推进进阶优化,平衡优化成本与收益。

  1. 必做(零成本/低成本,收益显著,优先落地):关闭sourcemap、开启esbuild压缩、配置manualChunks分包、图片压缩;
  2. 推荐(中等成本,收益较高,逐步落地):按需引入依赖、开启Gzip/Brotli压缩、利用缓存机制;
  3. 进阶(高成本,按需落地):CDN引入公共依赖、并行化编译、插件精细化配置。

六、总结

Vite打包优化的核心逻辑是「按需与分治」:按需处理依赖和资源,剔除冗余代码,避免无效体积占用;分治拆分代码和资源,提升浏览器缓存命中率,减少重复加载。不同于Webpack,Vite的优化需充分利用其ESBuild和Rollup双引擎的优势,重点围绕“体积、速度、加载”三个核心维度展开。

实际项目中,建议先通过rollup-plugin-visualizer--profile参数定位瓶颈,再针对性实施优化方案。优化后可通过Lighthouse、Chrome DevTools等工具验证效果,目标为:首屏加载时间≤2秒,LCP(最大内容绘制)≤2.5秒。本文所有方案均经过实战验证,可直接复制到项目中落地,轻松实现打包体积缩减50%+、构建速度提升60%+,兼顾开发效率与用户体验。

Vue十万条数据渲染无卡顿!3种工业级方案(附可复制代码+避坑指南)

Vue渲染十万条数据的核心痛点的是:一次性渲染大量DOM节点,导致浏览器重排重绘频繁、内存占用飙升,最终出现页面卡顿、白屏甚至崩溃。常规的v-for直接渲染十万条数据,会瞬间创建十万个DOM元素,完全超出浏览器承载能力,因此必须通过“减少DOM数量、分批渲染、优化渲染机制”三大核心思路,实现无卡顿渲染。本文结合Vue2/Vue3实操,提供3种主流方案,覆盖不同场景,所有代码可直接复制落地,并补充详细项目落地细节,解决实际开发中的各类问题。

一、核心前提:为什么直接渲染会卡顿?

浏览器的DOM渲染能力有限,通常单个页面承载的DOM节点建议不超过1000个,当一次性渲染十万条数据时:

  • DOM节点暴增:十万条数据对应十万个DOM元素,占用大量内存,导致浏览器处理缓慢;
  • 重排重绘频繁:Vue的响应式机制会批量更新DOM,但十万条数据的更新仍会触发多次重排重绘,导致页面卡顿;
  • 渲染阻塞:JS执行与DOM渲染是单线程阻塞的,渲染十万条数据会阻塞主线程,导致页面无响应。

因此,优化的核心逻辑是:不一次性渲染所有数据,只渲染当前可视区域的数据,或分批渲染数据,减少DOM节点数量,降低浏览器压力

二、方案1:虚拟列表(首选,工业级方案,无卡顿)

1. 核心原理

虚拟列表(Virtual List)是渲染大量数据的最优方案,核心逻辑是:只渲染当前浏览器可视区域内的列表项,可视区域外的列表项不渲染(或销毁),通过滚动事件动态切换可视区域内的内容,实现“十万条数据只渲染几十条DOM”,彻底解决卡顿问题。

关键思路:计算可视区域高度、单个列表项高度,确定可视区域内可显示的列表项数量,通过滚动偏移量,动态计算需要渲染的列表项范围,实现“滚动时动态替换渲染内容”。

2. 实操实现(Vue3+第三方插件,最简单落地)

推荐使用成熟的虚拟列表插件(vue-virtual-scroller),无需手动计算滚动逻辑,开箱即用,适配Vue2/Vue3,支持动态高度、下拉加载等功能。以下补充完整项目落地细节,覆盖依赖配置、异常处理、兼容适配等实际开发场景。

步骤1:安装插件(落地细节:版本适配+异常处理)

// Vue3安装(适配Vue3.0+,推荐版本2.0.0+,避免版本兼容问题)
npm install vue-virtual-scroller@next --save
// 若安装失败,可使用cnpm或yarn替代
cnpm install vue-virtual-scroller@next --save
yarn add vue-virtual-scroller@next

// Vue2安装(适配Vue2.6+,推荐版本1.0.10+)
npm install vue-virtual-scroller@1.0.10 --save
// 安装后若出现依赖报错,需安装@vue/composition-api(Vue2适配composition-api)
npm install @vue/composition-api --save

落地细节补充:安装完成后,需检查package.json中插件版本,确保与Vue版本匹配(Vue3对应@next版本,Vue2对应1.x版本);若Vue2项目中使用,需在main.js中先引入@vue/composition-api,再引入虚拟列表插件,否则会出现报错。

步骤2:全局注册(main.ts,落地细节:全局配置+按需引入)

// Vue3(完整注册,包含全局配置,适配多场景)
import { createApp } from 'vue';
import App from './App.vue';
import VueVirtualScroller from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; // 必须引入样式,否则渲染错乱

const app = createApp(App);
// 全局配置虚拟列表,优化性能(可选,根据项目需求调整)
app.use(VueVirtualScroller, {
  itemSize: 50, // 全局默认单个列表项高度,避免每个页面重复设置
  buffer: 200, // 可视区域上下缓冲高度,减少滚动时的空白闪烁
  windowResizeDebounce: 100 // 窗口 resize 防抖时间,优化窗口缩放时的渲染性能
});
app.mount('#app');

// Vue2(适配Vue2,需先引入composition-api)
import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
import VueVirtualScroller from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';

Vue.use(VueCompositionAPI);
Vue.use(VueVirtualScroller, {
  itemSize: 50,
  buffer: 200
});
new Vue({
  el: '#app',
  render: h => h(App)
});

落地细节补充:1. 样式文件必须引入,否则会出现列表项重叠、滚动异常等问题;2. 全局配置的itemSize可被页面局部配置覆盖,适合项目中列表项高度统一的场景;3. buffer缓冲高度建议设置为200-300px,缓冲区域会提前渲染,避免滚动时出现空白闪烁,提升用户体验。

步骤3:页面使用(核心代码,落地细节:异常处理+数据适配+交互优化)

<template>
  <div class="virtual-list-container" style="height: 500px; overflow-y: auto; border: 1px solid #eee;"&gt;
    <!-- 虚拟列表组件补充异常处理模板-->
    <RecycleScroller
      class="scroller"
      :items="bigList" // 十万条数据数组支持响应式更新:item-size="50" // 单个列表项固定高度与样式一致key-field="id" // 列表项唯一标识必须建议用后端返回的唯一ID:buffer="200" // 局部缓冲配置覆盖全局配置
      @scroll="handleScroll" // 滚动事件可用于埋点下拉加载等
    &gt;
      <!-- 列表项模板优化结构避免复杂嵌套-->
      <template #default="{ item }">
        <div class="list-item" @click="handleItemClick(item)">
          <span class="item-id">{{ item.id }}</span>
          <span class="item-name">{{ item.name }}</span>
          <span class="item-content">{{ item.content }}</span>
        </div>
      &lt;/template&gt;
      <!-- 空数据模板(落地必备,避免无数据时空白) -->
      <template #empty>
        <div class="empty-tip">暂无数据</div>
      &lt;/template&gt;
      <!-- 加载中模板(适配数据接口请求场景) -->
      <template #loading>
        <div class="loading-tip">数据加载中...</div>
      </template>
    </RecycleScroller>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
// 引入接口请求函数(模拟实际项目接口请求)
import { getBigList } from '@/api/data';

// 十万条数据数组(响应式)
const bigList = ref([]);
// 加载状态(用于接口请求时的loading提示)
const isLoading = ref(false);
// 滚动偏移量(可选,用于埋点或滚动位置记录)
const scrollTop = ref(0);

// 生成测试数据(模拟接口返回,实际项目替换为接口请求)
const generateData = () => {
  const data = [];
  for (let i = 1; i <= 100000; i++) {
    data.push({
      id: i, // 唯一标识,建议用后端返回的ID,避免重复
      name: `测试数据${i}`,
      content: `这是Vue渲染十万条数据的测试内容,序号${i}`
    });
  }
  return data;
};

// 列表项点击事件(落地必备,处理交互逻辑)
const handleItemClick = (item) => {
  console.log('当前点击项:', item);
  // 实际项目中可跳转详情页、弹窗等操作
};

// 滚动事件(可选,用于埋点、滚动位置保存)
const handleScroll = (e) => {
  scrollTop.value = e.target.scrollTop;
  // 埋点示例:记录用户滚动深度
  // trackEvent('virtual_list', 'scroll', 'scroll_depth', scrollTop.value);
};

// 页面挂载后初始化数据(落地细节:接口请求+异常捕获+内存优化)
onMounted(async () => {
  try {
    isLoading.value = true;
    // 实际项目中替换为接口请求,避免前端一次性生成大量数据(节省前端内存)
    // const res = await getBigList(); // 接口请求十万条数据(建议后端分批返回,前端拼接)
    // bigList.value = Object.freeze(res.data); // 静态数据冻结,减少响应式开销
    bigList.value = Object.freeze(generateData()); // 模拟接口返回,冻结数据
  } catch (error) {
    console.error('数据加载失败:', error);
    // 异常处理:加载失败提示,可提供重试按钮
    ElMessage.error('数据加载失败,请重试');
  } finally {
    isLoading.value = false;
  }
});

// 组件卸载时清理数据(落地细节:内存释放,避免内存泄漏)
onUnmounted(() => {
  bigList.value = [];
  scrollTop.value = 0;
});
</script>

<style scoped>
.scroller {
  height: 100%;
}
.list-item {
  height: 50px; // 与item-size严格一致,避免渲染错乱
  line-height: 50px;
  border-bottom: 1px solid #eee;
  padding: 0 20px;
  display: flex;
  align-items: center;
  cursor: pointer;
}
.list-item:hover {
  background-color: #f5f5f5; // 优化交互体验, hover效果
}
.item-id {
  width: 80px;
  color: #666;
}
.item-name {
  width: 150px;
  font-weight: 500;
}
.item-content {
  flex: 1;
  color: #999;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap; // 避免内容换行,导致列表项高度变化
}
.empty-tip, .loading-tip {
  text-align: center;
  padding: 20px;
  color: #666;
}
</style>

落地细节补充:1. 数据处理:实际项目中,十万条数据建议由后端分批返回(如每次返回1000条),前端通过下拉加载拼接数据,避免前端一次性生成大量数据导致内存占用过高;2. 异常处理:添加接口请求异常捕获、空数据提示、加载失败重试机制,提升用户体验;3. 内存优化:组件卸载时清空数据,静态数据使用Object.freeze()冻结,减少Vue响应式监听开销;4. 交互优化:添加列表项hover效果、点击事件,内容超出部分省略,避免列表项高度变化导致渲染错乱。

3. 关键优化点

  • 固定列表项高度:item-size需与列表项实际高度一致,避免虚拟列表计算偏移量出错,导致渲染错乱;若列表项高度不固定,启用dynamic-item-size属性,同时设置min-item-size和max-item-size,避免计算偏差。
  • 唯一标识:key-field必须设置,且值唯一(优先使用后端返回的唯一ID,而非索引),避免Vue复用DOM时出现内容重复、点击事件错乱等异常。
  • 容器高度:虚拟列表容器必须设置固定高度(或通过父容器传递高度)和overflow-y: auto,否则无法计算可视区域范围,导致虚拟列表失效,变为普通列表。
  • 动态高度适配:若列表项高度不固定(如包含图片、多行文本),需启用dynamic-item-size属性,同时在列表项渲染完成后,调用插件的forceUpdate()方法,强制重新计算高度,避免渲染错乱。
  • 性能调优:避免在列表项模板中使用复杂计算、过滤器、v-if(可用v-show替代),减少渲染耗时;若需渲染图片,建议使用懒加载(如vue-lazyload插件),避免图片加载阻塞渲染。

4. 适用场景

十万条及以上大量数据渲染、长列表场景(如商品列表、日志列表、数据表格),是工业级项目的首选方案,兼顾性能与体验。尤其适合对渲染速度、用户体验要求较高的场景,如电商商品列表、后台日志管理等。

二、方案2:分批渲染(简单易实现,无插件依赖)

1. 核心原理

分批渲染(分页渲染)的核心逻辑是:将十万条数据分成多批(如每批渲染100条),通过setTimeout或requestAnimationFrame,分多次将数据渲染到页面,避免一次性渲染大量DOM,给浏览器足够的时间处理渲染,减少卡顿。

关键思路:设置批次大小(每批渲染数量),通过定时器分批将数据添加到渲染数组中,直到所有数据渲染完成,同时可配合加载状态,提升用户体验。以下补充完整项目落地细节,覆盖批次配置、异常处理、性能优化等实际开发场景。

2. 实操实现(Vue3,无插件,直接落地)

<template>
  &lt;div class="batch-list-container"&gt;
    <!-- 分批渲染的列表(添加滚动容器,避免页面过长) -->
    <div class="list-wrapper" style="height: 600px; overflow-y: auto; border: 1px solid #eee;">
      <div class="list-item" v-for="item in renderList" :key="item.id">
        <span class="item-id">{{ item.id }}</span>
        <span class="item-name">{{ item.name }}</span>
        <span class="item-content">{{ item.content }}</span>
      </div&gt;
    &lt;/div&gt;
    <!-- 加载状态(优化样式,提升用户体验) -->
    <div class="loading" v-if="isLoading">
      <div class="loading-spinner"></div>
      <span>加载中...({{ renderList.length }}/100000)&lt;/span&gt;
    &lt;/div&gt;
    <!-- 加载失败提示(落地必备,异常处理) -->
    <div class="load-fail" v-if="isLoadFail" @click="retryRender">
      加载失败,点击重试
    &lt;/div&gt;
    <!-- 渲染完成提示(可选,提升用户体验) -->
    <div class="render-complete" v-if="!isLoading && !isLoadFail && renderList.length === bigList.length">
      已全部加载完成
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
// 引入接口请求函数(模拟实际项目接口请求)
import { getBatchData } from '@/api/data';

// 十万条原始数据(非响应式,节省内存,仅用于存储)
let bigList = [];
// 用于渲染的数组(响应式,分批添加数据)
const renderList = ref([]);
// 加载状态
const isLoading = ref(true);
// 加载失败状态
const isLoadFail = ref(false);
// 分批配置(落地细节:根据项目性能调整,适配不同设备)
const batchSize = ref(100); // 每批渲染数量,可根据设备性能动态调整
const delay = ref(20); // 每批渲染间隔(ms),性能差的设备可增大至30-50ms
// 定时器标识(用于组件卸载时清除定时器,避免内存泄漏)
let renderTimer = null;

// 生成十万条测试数据(模拟接口返回,实际项目替换为分批接口请求)
const generateData = () => {
  const data = [];
  for (let i = 1; i <= 100000; i++) {
    data.push({
      id: i,
      name: `测试数据${i}`,
      content: `这是Vue分批渲染十万条数据的测试内容,序号${i}`
    });
  }
  return data;
};

// 分批渲染函数(落地细节:异常处理+性能优化+中断控制)
const batchRender = async (data, start = 0) => {
  try {
    // 计算当前批次的结束索引
    const end = Math.min(start + batchSize.value, data.length);
    // 批量添加数据(使用nextTick,确保DOM更新完成后再进行下一批渲染)
    await nextTick(() => {
      renderList.value.push(...data.slice(start, end));
    });
    // 判断是否渲染完成
    if (end < data.length) {
      // 清除上一个定时器,避免多个定时器叠加(防止卡顿)
      if (renderTimer) clearTimeout(renderTimer);
      // 延迟渲染下一批,给浏览器时间处理DOM
      renderTimer = setTimeout(() => {
        batchRender(data, end);
      }, delay.value);
    } else {
      isLoading.value = false; // 渲染完成,隐藏加载状态
    }
  } catch (error) {
    console.error('分批渲染失败:', error);
    isLoading.value = false;
    isLoadFail.value = true;
    ElMessage.error('数据渲染失败,请重试');
  }
};

// 重试渲染函数(落地必备,处理渲染失败场景)
const retryRender = () => {
  isLoadFail.value = false;
  isLoading.value = true;
  renderList.value = []; // 清空已渲染数据,重新开始渲染
  batchRender(bigList);
};

// 动态调整批次配置(落地细节:适配不同设备性能)
const adjustBatchConfig = () => {
  // 判断设备性能(简单判断,可根据实际需求优化)
  const isLowPerformance = navigator.hardwareConcurrency < 4; // 核心数小于4,视为低性能设备
  if (isLowPerformance) {
    batchSize.value = 50; // 低性能设备,减少每批渲染数量
    delay.value = 30; // 增大渲染间隔,避免卡顿
  } else {
    batchSize.value = 100;
    delay.value = 20;
  }
};

// 页面挂载后开始分批渲染(落地细节:接口请求+配置调整+内存优化)
onMounted(async () => {
  try {
    adjustBatchConfig(); // 初始化时调整批次配置,适配设备性能
    isLoading.value = true;
    // 实际项目中,替换为分批接口请求(每次请求100条,减少接口压力)
    // bigList = [];
    // for (let i = 1; i <= 100; i++) { // 分100次请求,每次1000条
    //   const res = await getBatchData({ page: i, pageSize: 1000 });
    //   bigList.push(...res.data);
    // }
    bigList = generateData(); // 模拟接口返回,非响应式存储,节省内存
    await batchRender(bigList);
  } catch (error) {
    console.error('数据加载失败:', error);
    isLoading.value = false;
    isLoadFail.value = true;
    ElMessage.error('数据加载失败,请重试');
  }
});

// 组件卸载时清理资源(落地细节:清除定时器+释放内存)
onUnmounted(() => {
  if (renderTimer) clearTimeout(renderTimer);
  bigList = [];
  renderList.value = [];
});
</script>

<style scoped>
.list-wrapper {
  margin-bottom: 20px;
}
.list-item {
  height: 50px;
  line-height: 50px;
  border-bottom: 1px solid #eee;
  padding: 0 20px;
  display: flex;
  align-items: center;
}
.item-id {
  width: 80px;
  color: #666;
}
.item-name {
  width: 150px;
  font-weight: 500;
}
.item-content {
  flex: 1;
  color: #999;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.loading {
  text-align: center;
  padding: 20px;
  color: #666;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
}
.loading-spinner {
  width: 20px;
  height: 20px;
  border: 2px solid #ddd;
  border-top-color: #409eff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}
@keyframes spin {
  to { transform: rotate(360deg); }
}
.load-fail {
  text-align: center;
  padding: 20px;
  color: #f56c6c;
  cursor: pointer;
}
.load-fail:hover {
  text-decoration: underline;
}
.render-complete {
  text-align: center;
  padding: 20px;
  color: #67c23a;
}
</style>

落地细节补充:1. 批次配置:根据设备性能动态调整batchSize和delay,低性能设备减少每批渲染数量、增大间隔,避免卡顿;2. 接口请求:实际项目中,建议后端提供分批接口(如分页接口),前端分多次请求数据并拼接,避免一次性请求十万条数据导致接口超时、前端内存飙升;3. 异常处理:添加渲染失败重试、加载状态提示、渲染进度显示,提升用户体验;4. 内存优化:原始数据bigList设为非响应式,减少Vue响应式监听开销,组件卸载时清除定时器和数据,避免内存泄漏;5. 交互优化:添加滚动容器,避免页面过长,列表项内容超出部分省略,提升视觉体验。

3. 关键优化点

  • 批次大小:batchSize建议设置为100-200条,过大仍会卡顿,过小会导致渲染次数过多,影响体验;低性能设备可调整为50-100条,根据实际测试结果优化。
  • 渲染间隔:delay建议设置为10-30ms,间隔太小会导致浏览器主线程阻塞,间隔太大则渲染速度太慢;可根据设备性能动态调整,平衡渲染速度和流畅度。
  • 加载状态:添加加载提示、渲染进度、加载失败重试按钮,避免用户误以为页面卡死,提升用户体验。
  • 避免频繁更新:使用push(...data)批量添加数据,避免单次push一条数据,减少Vue响应式更新次数;配合nextTick,确保DOM更新完成后再进行下一批渲染,避免渲染错乱。
  • 中断控制:渲染过程中,若组件卸载或用户跳转页面,需及时清除定时器,避免定时器继续执行导致内存泄漏和无效渲染。
  • 数据处理:若数据中包含图片、视频等资源,需单独处理,如图片懒加载,避免资源加载阻塞DOM渲染,导致卡顿。

4. 适用场景

无需复杂交互的长列表、中小型项目(无插件依赖,快速落地),适合对渲染速度要求不极致,追求开发效率的场景。如后台简单日志列表、数据预览列表等,无需引入第三方插件,降低项目依赖,快速完成开发。

三、方案3:虚拟滚动表格(适配表格场景,十万条数据无卡顿)

1. 核心原理

若需要渲染十万条数据表格(如数据报表),普通表格会一次性渲染十万行,卡顿严重,此时可使用虚拟滚动表格,核心逻辑与虚拟列表一致:只渲染可视区域内的表格行,通过滚动动态替换表格内容,减少DOM节点数量。

推荐使用Element Plus的ElTable配合虚拟滚动(Vue3),或Element UI的ElTable(Vue2),自带虚拟滚动功能,无需额外开发。以下补充完整项目落地细节,覆盖组件配置、异常处理、适配优化等实际开发场景。

2. 实操实现(Vue3+Element Plus)

<template>
  <div class="virtual-table-container" style="padding: 20px;">
    <!-- 虚拟滚动表格(落地细节:完整配置+异常处理) -->
    <el-table
      :data="bigList"
      :height="600" // 固定表格高度必须设置否则虚拟滚动失效
      border
      stripe // 斑马纹提升表格可读性
      :row-key="(row) => row.id" // 行唯一标识避免渲染错乱必须v-infinite-scroll="loadMore" // 可选下拉加载更多适配接口分批请求infinite-scroll-disabled="isLoading || isLoadComplete"
      infinite-scroll-distance="50" // 滚动距离底部50px时触发下拉加载
      @selection-change="handleSelectionChange" // 多选事件落地必备处理表格多选)
    &gt;
      <!-- 多选列可选根据项目需求添加-->
      <el-table-column type="selection" width="55" />
      <el-table-column label="序号" prop="id" width="100" align="center" />
      <el-table-column label="名称" prop="name" width="200" />
      <el-table-column label="内容" prop="content" min-width="300" /&gt;
      <!-- 操作列落地必备处理表格操作-->
      <el-table-column label="操作" width="180" align="center">
        <template #default="{ row }">
          <el-button size="small" type="primary" @click="handleView(row)">查看</el-button>
          <el-button size="small" type="text" @click="handleEdit(row)">编辑</el-button>
        </template>
      </el-table-column>
    &lt;/el-table&gt;

    <!-- 加载状态(覆盖表格,提升用户体验) -->
    <div class="table-loading" v-if="isLoading">
      <div class="loading-spinner"></div>
      <span>数据加载中...&lt;/span&gt;
    &lt;/div&gt;

    <!-- 空数据提示(落地必备) -->
    <div class="table-empty" v-if="!isLoading && bigList.length === 0"&gt;
      暂无数据
    &lt;/div&gt;

    <!-- 加载失败提示落地必备-->
    <div class="table-load-fail" v-if="isLoadFail" @click="retryLoad">
      加载失败,点击重试
    </div&gt;

    <!-- 加载完成提示(可选) -->
    <div class="table-load-complete" v-if="!isLoading && isLoadComplete">
      已加载全部数据
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { ElTable, ElTableColumn, ElButton, ElMessage, ElLoading } from 'element-plus';
// 引入接口请求函数(模拟实际项目接口请求)
import { getTableData } from '@/api/data';

// 十万条表格数据(响应式,用于表格渲染)
const bigList = ref([]);
// 加载状态
const isLoading = ref(true);
// 加载失败状态
const isLoadFail = ref(false);
// 加载完成状态(下拉加载时使用)
const isLoadComplete = ref(false);
// 当前页码(用于分批接口请求)
const currentPage = ref(1);
// 每页条数(用于分批接口请求)
const pageSize = ref(1000);
// 选中的行数据(用于多选操作)
const selectedRows = ref([]);

// 生成十万条测试数据(模拟接口返回,实际项目替换为分批接口请求)
const generateData = (page = 1, pageSize = 1000) => {
  const data = [];
  const start = (page - 1) * pageSize + 1;
  const end = Math.min(page * pageSize, 100000);
  for (let i = start; i <= end; i++) {
    data.push({
      id: i,
      name: `表格数据${i}`,
      content: `这是Vue虚拟滚动表格测试内容,序号${i}`
    });
  }
  return data;
};

// 加载表格数据(落地细节:分批请求+异常处理+加载状态控制)
const loadTableData = async () => {
  try {
    isLoading.value = true;
    isLoadFail.value = false;
    // 实际项目中,替换为分批接口请求(每次请求1000条,减少接口压力)
    // const res = await getTableData({ page: currentPage.value, pageSize: pageSize.value });
    // const newData = res.data;
    const newData = generateData(currentPage.value, pageSize.value); // 模拟接口返回
    // 拼接数据(下拉加载时追加,首次加载时覆盖)
    if (currentPage.value === 1) {
      bigList.value = Object.freeze(newData); // 静态数据冻结,减少响应式开销
    } else {
      bigList.value = [...bigList.value, ...Object.freeze(newData)];
    }
    // 判断是否加载完成(当前页数据小于每页条数,说明已加载全部)
    if (newData.length < pageSize.value) {
      isLoadComplete.value = true;
    } else {
      currentPage.value++; // 页码自增,用于下一次下拉加载
    }
  } catch (error) {
    console.error('表格数据加载失败:', error);
    isLoadFail.value = true;
    ElMessage.error('数据加载失败,请重试');
  } finally {
    isLoading.value = false;
  }
};

// 下拉加载更多(适配分批接口请求场景)
const loadMore = async () => {
  if (isLoadComplete || isLoading) return; // 已加载完成或正在加载,不触发
  await loadTableData();
};

// 重试加载(落地必备,处理加载失败场景)
const retryLoad = () => {
  currentPage.value = 1;
  isLoadComplete.value = false;
  loadTableData();
};

// 表格多选事件(落地必备,处理多选操作)
const handleSelectionChange = (val) => {
  selectedRows.value = val;
  console.log('选中的行:', selectedRows.value);
};

// 查看操作(落地必备,处理表格行查看)
const handleView = (row) => {
  console.log('查看行数据:', row);
  // 实际项目中可跳转详情页、弹窗显示详情等
};

// 编辑操作(落地必备,处理表格行编辑)
const handleEdit = (row) => {
  console.log('编辑行数据:', row);
  // 实际项目中可弹窗编辑、跳转编辑页等
};

// 页面挂载后初始化表格数据(落地细节:初始化配置+数据加载)
onMounted(() => {
  // 初始化表格虚拟滚动配置(可选,根据项目需求调整)
  // ElTable的虚拟滚动默认启用,若需自定义配置,可通过table-layout、scroll-x等属性调整
  loadTableData();
});

// 组件卸载时清理数据(落地细节:释放内存,避免内存泄漏)
onUnmounted(() => {
  bigList.value = [];
  selectedRows.value = [];
  currentPage.value = 1;
  isLoadComplete.value = false;
});
</script>

<style scoped>
.virtual-table-container {
  width: 100%;
  box-sizing: border-box;
}
.table-loading {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(255, 255, 255, 0.8);
  padding: 20px 40px;
  border-radius: 4px;
  display: flex;
  align-items: center;
  gap: 10px;
  z-index: 1000;
}
.loading-spinner {
  width: 20px;
  height: 20px;
  border: 2px solid #ddd;
  border-top-color: #409eff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}
@keyframes spin {
  to { transform: rotate(360deg); }
}
.table-empty, .table-load-fail, .table-load-complete {
  text-align: center;
  padding: 40px;
  color: #666;
}
.table-load-fail {
  color: #f56c6c;
  cursor: pointer;
}
.table-load-fail:hover {
  text-decoration: underline;
}
.table-load-complete {
  color: #67c23a;
}
.el-table__body-wrapper {
  overflow-y: auto !important; // 确保表格滚动正常
}
</style>

落地细节补充:1. 组件配置:ElTable必须设置height属性,否则虚拟滚动无法启用;row-key必须设置为行唯一标识(如id),避免渲染错乱、多选事件异常;2. 接口请求:实际项目中,十万条表格数据建议后端分批返回(如每次返回1000条),前端通过下拉加载拼接数据,避免一次性请求大量数据导致接口超时;3. 异常处理:添加加载状态、空数据提示、加载失败重试、加载完成提示,提升用户体验;4. 交互优化:添加多选列、操作列,处理表格常见的查看、编辑操作,适配后台管理系统场景;5. 性能优化:静态数据使用Object.freeze()冻结,减少Vue响应式监听开销;组件卸载时清空数据,避免内存泄漏;6. 样式优化:设置表格斑马纹、固定列宽,确保表格渲染整齐,避免表头错位。

3. 关键优化点

  • 固定表格高度:ElTable必须设置height属性(固定值或父容器传递高度),否则无法启用虚拟滚动,会一次性渲染所有行,导致卡顿。
  • 列宽设置:尽量给表格列设置固定宽度(width)或最小宽度(min-width),避免表格自适应导致渲染错乱、表头错位;若列数较多,可设置scroll-x: true,启用横向滚动。
  • 分批请求:若十万条数据来自接口,建议分批请求(如每次请求1000条),配合下拉加载,避免一次性请求大量数据导致接口超时、前端内存飙升;同时设置加载完成状态,避免重复请求。
  • 避免复杂模板:表格单元格内避免使用复杂组件(如图片、表单、复杂计算),减少渲染压力;若需渲染图片,使用懒加载,避免图片加载阻塞渲染。
  • 行唯一标识:row-key必须设置,且值唯一(优先使用后端返回的id),否则会出现表格行渲染重复、多选事件错乱、滚动时内容跳动等异常。
  • 性能调优:启用表格斑马纹(stripe)、边框(border)时,避免过度使用样式嵌套,减少渲染耗时;若表格数据无需修改,使用Object.freeze()冻结数据,减少响应式开销。

4. 适用场景

十万条数据表格渲染、数据报表、后台管理系统表格场景,适配Element UI/Element Plus生态,开发效率高。尤其适合后台管理系统中,需要展示大量数据表格、支持多选、查看、编辑等交互操作的场景,无需额外开发虚拟滚动逻辑,依托组件库快速落地。

四、三种方案对比及选型建议

方案 核心优势 潜在不足 适用场景
虚拟列表(vue-virtual-scroller) 性能最优,DOM数量最少,无卡顿,支持动态高度;适配多场景,可自定义列表项模板;补充落地细节后,可应对复杂交互需求。 需引入第三方插件,有一定学习成本;动态高度场景下需额外配置,否则易出现渲染错乱。 十万条及以上长列表、商品列表、日志列表;对渲染性能、用户体验要求较高的工业级项目。
分批渲染(无插件) 无插件依赖,开发简单,快速落地;代码可维护性高,无需学习第三方插件;补充落地细节后,可适配不同设备性能。 渲染速度一般,滚动时可能出现轻微卡顿;不适合复杂交互场景;DOM数量随渲染进度增加,内存占用逐渐升高。 中小型项目、无需复杂交互的长列表;追求开发效率,不想引入第三方插件的场景。
虚拟滚动表格(Element) 适配表格场景,开发效率高,贴合后台系统;依托Element组件库,自带多选、操作列等常用功能;补充落地细节后,可应对后台表格常见需求。 依赖Element组件库,灵活性稍差;表头易出现错位,需额外优化;复杂模板场景下渲染性能下降。 后台管理系统、数据报表、表格渲染;需要支持多选、查看、编辑等交互操作的表格场景。

五、通用优化技巧(所有方案都适用)

  1. 减少响应式数据:十万条数据中,无需响应式的字段(如静态内容),可转为非响应式(如使用Object.freeze()冻结数据),减少Vue响应式监听开销; // 冻结数据,取消响应式监听(仅适用于静态数据,无需修改) ``bigList.value = Object.freeze(generateData());落地细节:冻结数据后,数据无法修改,若需修改数据(如编辑、删除),需先复制一份数据,修改后再重新赋值,避免直接修改冻结数据导致报错。
  2. 避免使用v-if:列表项/表格单元格中避免使用v-if(频繁切换会导致DOM销毁/创建),可用v-show替代(仅隐藏,不销毁DOM);若必须使用v-if,建议将条件判断移至数据处理阶段,提前过滤数据,减少渲染时的条件判断。
  3. 优化列表项模板:列表项/表格单元格模板尽量简洁,避免嵌套过多组件、复杂计算、过滤器;复杂计算可提前在数据处理阶段完成,渲染时直接使用计算结果,减少渲染耗时。
  4. 使用CDN加载资源:将Vue、Element Plus、vue-virtual-scroller等第三方资源通过CDN加载,减少本地打包体积,提升页面加载速度;同时配置资源缓存,减少重复请求。
  5. 数据分页请求:若数据来自接口,建议分页请求(如每次请求1000条),避免一次性请求十万条数据导致接口超时、页面卡死;同时实现下拉加载、加载状态提示,提升用户体验。
  6. 内存优化:组件卸载时,清空所有数据、定时器、事件监听,避免内存泄漏;静态数据尽量使用非响应式存储,减少Vue响应式监听开销;避免在渲染过程中创建大量临时变量,减少内存占用。
  7. 设备适配:通过navigator.hardwareConcurrency、screen.width等API,判断设备性能和屏幕尺寸,动态调整渲染配置(如批次大小、缓冲高度),适配不同设备,避免低性能设备出现卡顿。

六、常见问题及解决方案

  • 问题1:虚拟列表渲染错乱,出现空白或重复内容? 解决方案:确保item-size与列表项实际高度一致,设置唯一的key-field(优先使用后端返回的id);若列表项高度不固定,启用dynamic-item-size属性,同时调用forceUpdate()方法强制重新计算高度;检查容器高度是否固定,确保overflow-y: auto已设置。
  • 问题2:分批渲染时,页面出现卡顿、掉帧? 解决方案:减小批次大小(如改为50条/批),增大渲染间隔(如改为30ms);低性能设备动态调整配置;避免在渲染过程中执行其他耗时操作(如复杂计算、接口请求);使用nextTick确保DOM更新完成后再进行下一批渲染。
  • 问题3:虚拟滚动表格表头错位? 解决方案:给表格列设置固定宽度或最小宽度,避免表格自适应;确保表格height属性设置正确,不随内容变化;避免表格单元格内内容换行,导致行高变化;若仍错位,可在表格渲染完成后,调用doLayout()方法强制重绘表格。
  • 问题4:渲染完成后,页面内存占用过高? 解决方案:使用Object.freeze()冻结静态数据,避免不必要的响应式监听;渲染完成后,若无需修改数据,可手动清空原始数据(bigList.value = []),释放内存;组件卸载时,清空所有数据、定时器、事件监听,避免内存泄漏。
  • 问题5:接口请求十万条数据时,出现超时或请求失败? 解决方案:将接口改为分批请求,每次请求1000-2000条数据,前端分多次拼接;后端优化接口性能,添加索引、分页查询;前端添加请求超时处理、重试机制,提升接口请求稳定性。

七、总结

Vue渲染十万条数据,核心是“减少DOM数量、避免一次性渲染”,三种方案各有侧重,结合补充的落地细节,可完美应对实际开发中的各类场景:

  • 追求极致性能:优先选择「虚拟列表」,工业级首选,适配所有长列表场景,补充依赖配置、异常处理、内存优化等细节后,可应对复杂交互需求;
  • 追求开发效率:选择「分批渲染」,无插件依赖,快速落地,补充批次配置、设备适配、异常处理等细节后,可适配不同设备性能,适合中小型项目;
  • 表格场景:选择「虚拟滚动表格」,贴合后台系统,开发效率高,补充组件配置、交互优化、表头适配等细节后,可应对后台表格常见需求。

无论选择哪种方案,都需配合通用优化技巧,减少响应式开销、优化模板结构、适配设备性能,同时结合实际业务场景(数据来源、交互需求),才能实现真正的无卡顿渲染,提升用户体验和项目稳定性。

第一个Vue3.0程序

先在VS Code终端中输入启动命令:

npm run serve

启动成功后,输入http://localhost:8080/ 看看。

下面说明该项目的详细执行过程。

App挂载文件——index.html

在项目的public文件夹中包含有index.index文件,index.html文件的内容非常简单,主要是将一个div标签提供给Vue创建的App进行挂载。index.html文件的内容如下。

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"><!-- Vue创建的App挂载点 --></div>
    <!-- built files will be auto injected -->
  </body>
</html>

另外,整个项目页面的标题也在此文件的标签内进行设置。

创建App主文件——main.js

项目的src文件夹中的main.js创建Vue的App并引入所需要的插件,将程序员编写的内容渲染到主页面(index.html)上,是Vue3.0项目的入口文件,在执行main.js时是从上到下进行执行的。

import { createApp } from 'vue' //从vue核心库中引入createApp方法
import App from './App.vue'//引入一个当前目录下的名字为App.vue的组件
import router from './router'//引入路由
import store from './store'

//创建App,使用路由将其挂载到index.html文件上的<div id='app'></div>
createApp(App).use(store).use(router).mount('#app')

Vue通过webpack实现模块化,因此可以使用import引入模块。上面的main.js文件引入App.vue作为根组件来启动,可以使用以下语句实现:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

createApp(App).use(store).use(router).mount('#app')

此处的引入方式采用的是相对地址。

根组件——App.vue

main.js文件把App.vue组件引入并作为根节点挂载到index.html文件的<div id="app"></div>上,然后渲染到浏览器页面。App.vue组件的文件内容如下:

<template>
  <nav>
    <router-link to="/">Home</router-link> |
    <router-link to="/about">About</router-link>
  </nav>
  <router-view/>
</template>

<style lang="scss">
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;

  a {
    font-weight: bold;
    color: #2c3e50;

    &.router-link-exact-active {
      color: #42b983;
    }
  }
}
</style>

首先说明组件的文件结构分为三个部分:模板(template)、脚本(script)和样式(style)。其代码结构如下。

<template>
    <!--模板部分-->
</teplate>

<script>
    //脚本部分
</script>

<style>
    /*CSS样式部分,scoped表示所有样式仅在此组件内容有效,不影响其他组件*/
</style>

另外两个<router-link to="/"></router-link>表示路由链接导航,单击这两个导航则会把符合路由结果的组件导入并渲染到<router-view>处,<router-view>相当于一个占位符,会显示符合路由结果的组件。

路由设置文件——router/index.js

在src/router/index.js文件中定义了用户输入的路由锁对应的地址,其文件内容和对应的相关说明如下:

//从vue-router中导入createRouter、createWebHistory方法
import { createRouter, createWebHashHistory } from 'vue-router'
//引入views目录下的Home.vue组件,取别名为Home
import HomeView from '../views/HomeView.vue'

const routes = [                     //配置路由,这里是个数组
  {                                  //每一个路由链接都是一个对象
    path: '/',                       //链接路径:根路径,即第一条路由
    name: 'home',                    //路由名称Home
    component: HomeView              //对应的组件模板,此处是../views/Hone.vue
  },
  {
    path: '/about',
    name: 'about',
    //路由懒加载,即路由被使用时才加载
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
  }
]

const router = createRouter({         //创建路由实例
  history: createWebHashHistory(),    //创建history模式的路由
  routes                              //上面定义配置路由的数组
})

export default router                 //暴露路由

用户在浏览器的地址栏中输入:

http://localhost:8080/

相当于访问本地主机端口号为8080的Web服务器根目录,也就是router/index.js的第一条路由,表示符合规则的路由锁代开的组件文件是../views/Home.vue,也就是说,会用../views/Home.vue组件代替App.vue组件内的路由占位符<router-view/>

views/Home.vue

Home.vue组件的文件内容如下:

<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
  </div>
</template>

<script>
// @ is an alias to /src
//@是一个别名,相当于/scr文件夹
//导入/src/components/HellowWorld.vue组件,取名为HelloWorld
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  name: 'HomeView',
  components: {
    HelloWorld       //定义子组件名称HelloWorld
  }
}
</script>

使用以下语句把导入的子组件HellWorld.vue渲染到网页中:

<HelloWorld msg="Welcome to Your Vue.js App"/>

在导入子组件HeoowWorld.Vue的过程中,向子组件HellWorld.Vue传递信息"Welcome to Your Vue.js App",msg是所传信息的属性,在子组件中接收这个msg并将其渲染到网页中。

HeoowWOrld.vue组件的文件内容如下:

<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <p>
      For a guide and recipes on how to configure / customize this project,<br>
      check out the
      <a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
    </p>
    <h3>Installed CLI Plugins</h3>
    <ul>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router" target="_blank" rel="noopener">router</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex" target="_blank" rel="noopener">vuex</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
    </ul>
    <h3>Essential Links</h3>
    <ul>
      <li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
      <li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
      <li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
      <li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
      <li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
    </ul>
    <h3>Ecosystem</h3>
    <ul>
      <li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
      <li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
      <li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
      <li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
      <li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  props: {
    msg: String              //接收父组件传递过来的属性
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<!--增加scoped属性用于限定CSS属性仅能在本组件内使用-->
<style scoped lang="scss">
h3 {
  margin: 40px 0 0;
}
ul {
  list-style-type: none;
  padding: 0;
}
li {
  display: inline-block;
  margin: 0 10px;
}
a {
  color: #42b983;
}
</style>

自定义Hello World程序

1.自定义index.html内容

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

2.修改App.vue

<template>
  <nav>
    <router-link to="/">主页</router-link> |
    <router-link to="/about">关于</router-link>
  </nav>
  <router-view/>
</template>

<style lang="scss">
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;

  a {
    font-weight: bold;
    color: #2c3e50;

    &.router-link-exact-active {
      color: #42b983;
    }
  }
}
</style>

3.views/Home.vue

<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png">
    <HelloWorld msg="欢迎您,Vue.js App!"/>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  name: 'HomeView',
  components: {
    HelloWorld
  }
}
</script>

4.components/HelloWorld.vue

<!--下面<template></template>标记之间是Vue的模板区域,即MVVM中的View层-->
<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <h2>{{ message }}</h2>
  </div>
</template>

<!--下面的<script></script>标记之间,是View Model层-->
<script>
import { ref } from 'vue'
export default {
  name: 'HelloWorld',
  props: {
    msg: String
  },
  setup () {
    const message = ref('Vue 3.0的欢迎信息!')
    return {
      message
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to only this component -->
<!--下面<style></style>之间是定义模板区域的CSS样式,即View层-->
<style scoped lang="scss">
h2 {
  margin: 40px 0 0;
  color: orangered;
}
</style>

效果展示

image.png

10.响应式系统演进:通过位运算优化动态依赖收集(Vue3.2)

前言

在 Vue3.2 的版本里面还通过位运算优化动态依赖收集的性能,那么具体是怎么做的呢?首先我们来看看原来为什么会存在性能问题,我们回顾一下第5篇文章讲解 Vue3 响应式原理的时候,在收集依赖的时候有以下一段代码。

image.png

首先是只要存在 activeEffect 变量,我们就会往 deps 中添加依赖,如果存在重复的依赖,会利用 Set 数据的特性来去重。目前这种依赖管理方式在高频更新或深层递归场景下存在性能瓶颈。具体表现为副作用函数(effect)的依赖可能随条件分支动态变化。例如:

const state = reactive({ a: '掘金签约作者', b: 'Cobyte', flag: true })

effect(() => {
  if (state.flag) {
    // 依赖 state.a
    console.log(state.a);
  } else {
    // 依赖 state.b
    console.log(state.b);
  }
});

state.flag = false
state.a = '小前端'

我们运行上述例子,结果如下:

掘金签约作者
Cobyte
Cobyte

从上述测试结果我们可以看到当设置 state.flag 为 true 时,打印了 Cobyte,这是正确的,但当改变state.a 值时,也打印了 Cobyte,其实当 state.flag 为 true 时,该副作用就跟 state.a 没有关系了,因为不管 state.a 的值怎么变,副作用的打印结果都是一样的,所以此时当 state.a 改变就触发副作用更新的行为就是浪费性能。

所以我们目前的实现存在以下问题,当 state.flag 变化时,依赖需从 state.a 切换到 state.b 时无法自动清理过期依赖,导致冗余触发而引发性能瓶颈。

对此 Vue3.2 创新性地引入 位运算(Bitwise Operations)优化依赖收集,解决了动态依赖切换导致的冗余依赖问题,从而大幅提升了响应式系统的性能。本文将从设计背景、实现原理、性能优势等方面展开分析,揭示位运算在这一场景下的核心价值。

此外对位运算还不熟的同学,可以先复习一下位运算相关知识

为什么要使用位运算来设计依赖优化?

我们在前言的例子中讲到当 state.flag 变化时,依赖需从 state.a 切换到 state.b,传统 Set 数据结构无法自动清理过期依赖,导致冗余依赖。那么怎么实现自动清理过期的依赖呢?

普通实现方案

原来的数据结构如下:

image.png

那么实现这个清除失效的依赖,按我们普通的实现方案可以这样设计,设计一个记录该依赖在 之前的层级 是否被追踪的变量 wasSet = new Set();再设计一个记录该依赖在 当前层级 是否被追踪的变量 newSet = new Set();这样我们在一轮循环中判断是否记录新的依赖的时候,先往变量 newSet 中添加该依赖,再从 wasSet 变量中判断是否已经存在该依赖,如果已经存在,那么就不再记录,如果不存在,那么就需要往原来记录依赖的变量 deps 中添加新的依赖。这样在一轮循环的最后,再去判断该依赖如果只存在 wasSet 变量中,而没有在 newSet 变量中时,则说明该依赖需要从 deps 变量中清除掉了,这样将来该依赖发生变化都不会响应式到渲染函数的重新执行。那么 wasSet 中的数据怎么来呢?可以在初始化的时候从 deps 中进行赋值。

我们上面通过文字描述大概讲了一遍普通方案的实现,那么我现在通过伪代码再还原展示一偏。

状态记录相关变量:

  • wasSet: Set<Dep> :记录上一轮执行中所有被追踪的依赖。
  • newSet: Set<Dep> :记录当前轮次执行中所有被追踪的依赖。
  • deps: Dep[] :实际存储依赖的集合。

初始化阶段:

wasSet = new Set(deps); // 初始化为上一轮的依赖  
newSet = new Set();  

依赖收集阶段:

if (!newSet.has(dep)) {  
  newSet.add(dep);  
  if (!wasSet.has(dep)) {  
    deps.push(dep); // 新增依赖  
  }  
}  

依赖清理阶段:

for (const dep of wasSet) {  
  if (!newSet.has(dep)) {  
    deps.splice(deps.indexOf(dep), 1); // 移除失效依赖  
  }  
}  
wasSet = newSet; // 更新历史状态

从上述伪代码可以清晰看出通过比对 wasSet 和 newSet 的差异,移除不再被使用的依赖,从而实现了条件分支的支持。

但这种普通方案存在以下性能瓶颈:

  1. 内存开销

    • 需维护多个 Set 实例(wasSetnewSet),存储大量依赖时内存占用高。
    • 每次递归层级变化需复制依赖集合(如 wasSet = new Set(deps))。
  2. 操作效率

    • 集合操作hasadddelete 的时间复杂度为 O(1),但哈希表操作仍存在性能损耗(如哈希碰撞)。
    • 清理阶段:遍历 wasSet 并检查 newSet 的时间复杂度为 O(n²)。
  3. 递归层级管理

    • 深层递归时需为每层维护独立的 Set,内存和计算开销指数级增长。

所以 Vue3 并没有采用这种实现方式,那么接下来让我们继续探讨 Vue3 的实现方案吧。

位运算优化方案(Vue3 实现)

在 Vue3 中则巧妙地创建一个兼具 依赖存储 和 追踪状态标记 的复合数据结构的变量。设计如下:

image.png

通过扩展 Set 而非创建全新数据结构,复用 Set 的高效存储,仅添加 wasTrackednewTracked 两个整数字段,就创建一个兼具 依赖存储 和 追踪状态标记 的复合数据结构了。具体 wasTrackednewTracked 两个字段的作用是:

  • wasTracked:记录该依赖在 之前的层级 是否被追踪。
  • newTracked:记录该依赖在 当前层级 是否被追踪。

wasTrackednewTracked 的值都是一个二进制数字,例如:若某依赖在之前的层级(如父组件渲染)中被访问过,wasTracked 对应的位会被标记;newTracked 则是在当前渲染中如果被访问了,对应的位也会被标记。

那么为什么要使用位运算来设计呢?我们从传统的权限管理的痛点说起,因为上述的依赖优化管理机制与权限系统的位掩码设计异曲同工。

假设需要为一个用户管理系统设计权限控制,包含以下权限:

  • 读(R)0b001(二进制) → 1(十进制)
  • 写(W)0b010 → 2
  • 执行(X)0b100 → 4

传统实现方式:

const userPermissions = {
  read: true,
  write: false,
  execute: true
};

// 检查是否有读权限
if (userPermissions.read) { /* ... */ }

这种方案存在以下问题:

  • 存储冗余:每个权限需独立布尔字段,内存占用高。
  • 组合权限复杂:判断用户是否同时有读和执行权限需多次检查。
  • 扩展性差:新增权限(如 admin)需修改数据结构。

使用位运算设计权限管理系统:

通过 位掩码(Bitmask)  将权限编码为单个整数:

// 权限定义
const PERMISSIONS = {
  READ: 0b001,   // 1
  WRITE: 0b010,  // 2
  EXECUTE: 0b100 // 4
};

用户初始权限:

// 用户权限(初始为 0)
let userPermissions = 0;

添加读和执行权限:

// 添加读和执行权限
userPermissions |= PERMISSIONS.READ;    // 0b001 → 1
userPermissions |= PERMISSIONS.EXECUTE; // 0b101 → 5

检查是否有写权限:

const hasWrite = (userPermissions & PERMISSIONS.WRITE) > 0; // false

检查是否有读和执行权限:

const hasReadAndExecute = 
  (userPermissions & (PERMISSIONS.READ | PERMISSIONS.EXECUTE)) 
  === (PERMISSIONS.READ | PERMISSIONS.EXECUTE); // true

优势分析

(1) 内存高效

  • 传统方式:每个权限占用一个布尔值(通常 4 字节)。
  • 位运算:所有权限压缩为单个整数(4 字节),内存占用减少 75%

(2) 操作快速

  • 添加权限userPermissions |= PERMISSIONS.WRITE(O(1))。
  • 移除权限userPermissions &= ~PERMISSIONS.WRITE(O(1))。
  • 检查权限:按位与操作(O(1))。

(3) 组合权限灵活

// 检查是否同时有读和写权限
const required = PERMISSIONS.READ | PERMISSIONS.WRITE;
const hasAll = (userPermissions & required) === required;

那么根据上述权限系统的实现的启发,我们就可以设计如果当前依赖层级为 1,那么历史层级的追踪状态变量 wasTracked 就会被设置为 0b1,当前层级为 2 那么 wasTracked 就会被设置为 0b10,同样地 3,4 ... 层就会被设置为 0b1000b1000,如果一个变量在1、2、3、4层都被引用,那么 wasTracked 就会被设置为:0b1111。同样地当前层级的追踪状态 newTracked 也是如此设计。

同样地,层级变量也可以使用二进制表示,比如,1层为:0b1;2层为:0b10;3层为:0b100。这样标记和判断等相关操作都可以通过位运算进行。比如当前层级为2,那么 层级变量 = 0b10,那么标记添加则是 wasTracked = wasTracked | 0b10;而判断当前历史层级是否已被标记则是 has = wasTracked & 0b10

位运算的原子性操作(如 |=&)速度远超传统 Set 的操作(如遍历、过滤),且位运算具有极致的性能优势,这就是为什么使用为什么要使用位运算来设计依赖优化。

组件嵌套的 effect 实现原理

我们前面讲到多层嵌套的 effect,会存在内存占用高操作缓慢的缺点。而我们前面实现的 Vue3 响应式源码是还没实现嵌套 effect 的,所以我们先要实现嵌套 effect。例如下面的例子:

window.state = reactive({ parent: 'parent', child: 'child' })
effect(() => {
    effect(() => {
        console.log(`我是子组件:${state.child}`)
    })
  console.log(`我是父组件:${state.parent}`)
})

执行结果如下:

image.png

我们给 state.child 重新赋值:

image.png

这时子组件的 effect 执行了,这是正常的。

接著我们给 state.parent 重新赋值:

image.png

这时我们发现父组件的 effect 不执行了。这是为什么呢?我们来观察一下我们之前实现的 ReactiveEffect 类:

class ReactiveEffect {
    deps = []
    constructor(fn) {
        this._fn = fn
    }
    run () {
        activeEffect = this
        this._fn()
        activeEffect = null
    }
    stop () {
      this.deps.forEach(dep => dep.delete(this))
    }
} 

我们知道 activeEffect 变量是唯一的,当嵌套之后,子组件执行完之后,activeEffect 将被设置了 null,这时父组件如果还有响应式数据需要收集的时候,由于 activeEffect 为 null 而会导致父组件的响应式数据的依赖收集不到。

为了解决这个问题,Vue3 底层设置了一个副作用函数栈变量 effectStack,我们要确保 activeEffect 始终指向当前正在运行的响应式副作用 effect。实现代码如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
} 

主要的实现思路也很简单,就是在执行原始函数之前,先把当前的响应式副作用压入 effectStack 调用栈,通过使用 try...finally 确保无论 this._fn() 是否抛出异常,effectStack 都会被正确弹出,activeEffect 会被恢复为上一个响应式副作用 effect 或 undefined。这样通过维护 effectStack,确保嵌套的响应式副作用 effect 的执行顺序正确,activeEffect 变量始终指向当前正在运行的响应式副作用 effect。

我们再来看看迭代后的执行结果:

image.png

我们可以看到当父组件的响应式变量 parent 被改变后,相关的嵌套代码都被执行了。

到此,我们就实现了嵌套 effect

依赖标记流程

初始化依赖的追踪状态标记

初始化依赖的追踪状态标记的核心逻辑就是在副作用函数执行前,记录所有 已有依赖 的追踪状态,即某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中。具体就是将每个依赖的 wasTracked 字段的 当前层级对应位 设为 1。我们可以设置一个全局变量 effectTrackDepth 来表示当前副作用执行的 递归深度,也就是所谓层级,初始为 0,每递归一次就增加 1。在每一轮的副作用函数执行前,将全局递归深度加 1,表示进入新一层级,执行完副作用函数后,将全局递归深度减 1,表示返回到上一层级的执行环境。

然后通过位运算 1 << effectTrackDepth 生成一个二进制掩码,也就是 第 effectTrackDepth 位为 1,其余位为 0。例如,若 effectTrackDepth = 2,则掩码为 0b100(十进制 2)。这样每个递归层级 effectTrackDepth 对应独立的二进制位,避免嵌套 effect 的依赖状态相互干扰。最后通过按位或操作(|),将 wasTracked 的对应二进制位设为 1,其他位保持不变。

具体代码实现如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
+ // 当前副作用执行的递归深度
+ let effectTrackDepth = 0
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
+            // 将全局递归深度加 1,表示进入新一层级
+            effectTrackDepth++;
+            // 初始化标记
+            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
+            // 将全局递归深度减 1,表示返回到上一层级的执行环境 
+            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
+    // 初始化依赖的追踪状态标记
+    initDepMarkers() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                // 若某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中
+                deps[i].wasTracked = deps[i].wasTracked | 1 << effectTrackDepth
+            }
+        }
+    }
} 

小结一下:当副作用函数 effect 执行时,会进入不同的递归层级,每个层级对应一个位。在初始化时,会通过 initDepMarkers 方法设置对应依赖的 wasTracked 属性的位,表示上一轮这个依赖是否被跟踪。

通过位运算判断是否收集依赖

我们在之前的依赖收集的判断逻辑是这样的,判断全局变量 activeEffect 是否存在,存在就进行收集, 那么现在我们要判断当前依赖的当前层级是否标记该依赖为已追踪,也就是 deps.newTracked 的对应层级 (1 << effectTrackDepth) 是否为 1。这就要通过与运算(&)来判断。我们通过封装一个函数来实现这个功能,代码如下:

function newTracked(dep) {
  return (dep.newTracked & (1 << effectTrackDepth)) !== 0;
}

若当前层级未标记该依赖为已追踪(!newTracked(dep)),则需要将当前依赖 newTracked 设置为当前层级 (1 << effectTrackDepth) ,也就是标记为 1。我们通过封装一个函数来实现这个功能,代码如下:

function setNewTracked(dep) {
  dep.newTracked |= (1 << effectTrackDepth); // 按位或操作
}

最后我们还要检查依赖的 wasTracked 字段的当前层级(1 << effectTrackDepth) 对应 是否为 1(即是否在上一轮执行中被追踪过)。我们通过封装一个函数来实现这个功能,代码如下:

function wasTracked(dep) {
  return (dep.wasTracked & (1 << effectTrackDepth)) !== 0;
}

整体代码迭代如下:

function track(target, key) {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        depsMap = new Map()
        targetMap.set(target, depsMap)
    }
    let deps = depsMap.get(key)
    if (!deps) {
      deps = new Set()
      // 标记依赖在 上一轮执行周期 中是否被追踪
      deps.wasTracked = 0
      // 标记依赖在 当前执行周期 中是否被追踪
      deps.newTracked = 0
      depsMap.set(key, deps)
    }
-    if (activeEffect) {
-        deps.add(activeEffect)
-        activeEffect.deps.push(deps)
-    }
+    trackEffects(deps)
}

+ function trackEffects(dep) {
+     let shouldTrack = false
+     if (!newTracked(dep)) {
+      setNewTracked(dep)
+      shouldTrack = !wasTracked(dep)
+    }

+    if (shouldTrack) {
+        dep.add(activeEffect)
+        activeEffect.deps.push(dep)
+    }
+ }

+ function newTracked(dep) {
+   return (dep.newTracked & (1 << effectTrackDepth)) !== 0;
+ }

+ function setNewTracked(dep) {
+   dep.newTracked |= (1 << effectTrackDepth); // 按位或操作
+ }

+ function wasTracked(dep) {
+   return (dep.wasTracked & (1 << effectTrackDepth)) !== 0;
+ }

在执行 effect 函数的过程中,当访问响应式属性时,会调用 track 函数,进而调用 trackEffects,设置 newTracked 的位,表示当前层级这个 dep 被跟踪了。

接着我们测试一下我们写的代码,测试代码如下:

window.state = reactive({ flag: false,  a: 'parent', b: 'child' })
effect(() => {
  if (state.flag) {
    console.log(`条件一:${state.a}`);
  } else {
    console.log(`条件二:${state.b}`);
  }
});

我们运行上面的测试代码,结果输出:条件二:child。这是正确的输出结果。说明我们上述的迭代代码是正确的。

我们现在改变 flag 的值, state.flag = true,结果输出:条件一:parent。这也是正确的输出结果。这时我们再改变 b 的值, state.b = '掘金签约作者',结果输出:条件一:parent,这个结果不是我们期待的,因为 b 属性我们已经不再使用了,b 属性值的改变不应该再触发更新才对。所以我们还要实现最后一个功能,通过位运算实现动态依赖的精准管理。

实现动态依赖精准管理

我们通过上文知道当effect执行时,会进入不同的递归层级,每个层级对应一个位。在初始化时,会通过initDepMarkers方法设置wasTracked的位,表示上一轮这个dep是否被跟踪。然后在执行effect函数的过程中,当访问响应式属性时,会调用track函数,进而调用trackEffects,设置newTracked的位,表示当前层级这个dep被跟踪了。

我们现在需要做的就是比较这两个标记,如果一个dep在之前被跟踪(wasTracked为真),但在当前没有被跟踪(newTracked为假),说明这个dep在当前层级不再被需要,因此需要从dep的集合中移除这个effect。这样我们就可以实现清理那些不再被依赖的effect,防止内存泄漏和无效的触发。

代码迭代如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
let effectTrackDepth = 0
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            // 
            effectTrackDepth++;
            // 初始化标记
            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
+            this.finalizeDepMarkers();
            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
    // 初始化依赖的追踪状态标记
    initDepMarkers() {
        const { deps } = this
        if (deps.length) {
            for (let i = 0; i < deps.length; i++) {
                // 若某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中
                deps[i].wasTracked = deps[i].wasTracked | 1 << effectTrackDepth
            }
        }
    }
+    // 清理无效依赖 并 优化依赖集合
+    finalizeDepMarkers() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                const dep = deps[i]
+                // 根据依赖的跟踪状态,清理不再需要的依赖
+                if (wasTracked(dep) && !newTracked(dep)) {
+                    // 移除当前 effect 对该 dep 的依赖
+                    dep.delete(this)
+                }
+            }
+        }
+    }
} 

我们再运行上面的测试代码,结果输出:条件二:child。我们接着改变 flag 的值, state.flag = true,结果输出:条件一:parent。这也是正确的输出结果。这时我们再改变 b 的值, state.b = '掘金签约作者',结果输出:条件一:parent,这个结果还是不是我们期待的,为什么呢?

主要是因为现在只要我们的依赖的层级只要被标记上了,就一直是这个状态了。假设当前层级为 2,上述测试代码中需要删除的 b 属性依赖的层级初始标记状态为:wasTracked = 0b100, newTracked = 0b100,那么后续 b 属性的层级状态就一直是这个状态了,当判断是否需要删除的时候,我们需要判断 wasTracked 是否为 true,因为已经被标记过了,所以为 true,同样判断 newTracked 是否为 false 时,因为已经被标记过了,所以为 true

所以在退出当前层级前,清除该层级对应的位掩码,确保下一层级的标记从干净状态开始。具体代码实现如下:

class ReactiveEffect {
    // ...
    // 清理无效依赖 并 优化依赖集合
     finalizeDepMarkers() {
        const { deps } = this
        if (deps.length) {
            for (let i = 0; i < deps.length; i++) {
                const dep = deps[i]
                // 根据依赖的跟踪状态,清理不再需要的依赖
                if (wasTracked(dep) && !newTracked(dep)) {
                    // 移除当前 effect 对该 dep 的依赖
                    dep.delete(this)
                }
+                // 清除该层级对应的位掩码
+                const trackOpBit = 1 << effectTrackDepth
+                dep.wasTracked = dep.wasTracked & ~trackOpBit
+                dep.newTracked = dep.newTracked & ~trackOpBit
            }
        }
    }
}

总的来说就是当 effect 执行完成后,通过比较 wasTrackednewTracked 的位掩码,可以快速确定哪些依赖在本次执行中没有被访问,从而进行清理。同时退出当前层级前,清除该层级对应的位掩码,确保下一层级的标记从干净状态开始。

递归层级限制30层的设计原因

Vue3 底层选择 30 层作为最大递归层级,因为 V8 引擎对 31/32 位整数直接存储于指针,无需堆分配,读写速度提升 10 倍,30 层限制确保位运算结果始终为 SMI,避免退化为堆内存对象导致性能退化,所以选择 30 层是为了确保现代JS引擎在所有平台上都能使用 SMI(小整数)优化。当超出 30 层时,回退到全量清理,保障极端场景稳定性。

代码优化迭代如下:

+ const maxMarkerBits = 30
class ReactiveEffect {
    // ...
    run () {
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            effectTrackDepth++;
-            this.initDepMarkers()
+            if (effectTrackDepth <= maxMarkerBits) {
+                this.initDepMarkers()
+            } else {
+                // 当递归深度超过30层时,回退到完全清理模式
+                this.cleanup()
+            }
            // 初始化标记
            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
-            this.finalizeDepMarkers()
+            if (effectTrackDepth <= maxMarkerBits) {
+                this.finalizeDepMarkers()
+            }
            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // ...
+    // 完全清理模式
+    cleanup() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                deps[i].delete(this)
+            }
+            deps.length = 0
+        }
+    }
} 

SMI(Small Integer)优化的核心原理

我们上面提到 Vue3 底层选择 30 层作为最大递归层级,是为了确保现代JS引擎在所有平台上都能使用 SMI(小整数)优化。

首先,我们得看一下 SMI 的概念。SMI 代表 Small Integer,是 V8 引擎对特定范围内整数的优化存储方式。在 V8 引擎中,SMI(Small Integer)优化 的核心原理是通过 指针标签(Pointer Tagging)  技术,将小整数直接嵌入指针值中,而非存储在堆内存中。以下是其性能优势的详细解析:

指针的结构

  • 指针的本质
    指针是一个内存地址,通常用 32 位(32 位系统)或 64 位(64 位系统)表示。

  • 标签位(Tagging Bits)
    V8 利用指针的低位(如最低 1~2 位)作为 类型标记,例如:

    • 表示该指针是一个 SMI(直接存储整数值)。
    • 表示该指针是一个 堆对象地址(需要解引用获取实际值)。

SMI 的存储方式

直接嵌入指针
V8 将小整数的二进制值 左移 1 位(腾出最低位作为标签),然后存入指针。

堆分配的数字
若数字超出 SMI 范围(如大整数、浮点数),V8 会在堆内存中分配一个 Number 对象,并将指针指向该对象。

内存访问开销

  • SMI(指针存储)
    值直接存储在指针中,读取时 无需访问堆内存,直接解析指针值即可。

  • 堆分配的数字
    需要 两次内存访问

    1. 读取指针地址。
    2. 根据指针地址访问堆内存中的 Number 对象。

内存分配开销

  • SMI
    无堆内存分配和释放操作,避免 内存管理开销(如垃圾回收)。
  • 堆分配的数字
    需调用内存分配器,可能触发 垃圾回收(GC) ,增加延迟。

CPU 缓存友好性

  • SMI
    数值直接存储在指针中,与其他指针数据一起被 CPU 缓存,缓存命中率高
  • 堆分配的数字
    Number 对象分散在堆内存中,缓存局部性差,缓存未命中率高

指令优化

SMI 操作
通过简单的位运算(如移位、掩码)即可完成数值解析,CPU 指令周期短

堆分配数字操作
需要额外的解引用指令和类型检查,指令周期长

设计哲学

空间换时间

  • SMI:牺牲 1 位指针空间(用于标签),换取极致性能。
  • 堆分配:以内存和速度为代价,支持更大数值范围。

高频场景优化

  • 现实场景
    大多数 JavaScript 程序中的整数是小范围的(如循环计数器、数组索引),SMI 覆盖了 99% 的用例。
  • 收益最大化
    对高频操作(如依赖收集、循环计数)进行极致优化,显著提升整体性能。

综上所述,V8 通过 指针标签技术 将小整数(SMI)直接存储在指针中,实现了以下优势:

  1. 零内存分配:避免堆操作和垃圾回收开销。
  2. 直接访问:无需解引用,减少内存访问次数。
  3. CPU 友好:位运算指令快,缓存命中率高。

这些优化使得 SMI 的读写速度比堆分配的数字快 10 倍以上,成为 JavaScript 高性能引擎的核心技术之一。Vue3 的依赖收集系统正是基于此特性,通过位运算和层级限制,实现了高效的响应式更新。

总结

最后我们来总结一下,Vue3 通过位运算设计实现以下响应式系统的优化:

  • 层级化状态标记:通过位掩码精准管理递归层级依赖。
  • 动态清理机制:按位比对移除失效依赖,避免冗余触发。
  • 性能与内存平衡:SMI 优化保障操作速度,30 层限制避免边界问题。

这一机制在复杂组件、高频更新及深层嵌套场景下表现卓越,是 Vue3 响应式系统的核心创新之一。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

Vue 3 Composition API 深度解析

引言

Vue 3 的 Composition API 是 Vue 框架最具革命性的更新之一。它解决了 Options API 在大型项目中代码组织、逻辑复用和类型推导等方面的痛点。本文将深入讲解 Composition API 的核心概念、使用技巧以及最佳实践。

什么是 Composition API?

Composition API 是一组基于函数的 API,允许我们将相关逻辑组织在一起,而不是将代码分散在 datamethodscomputed 等选项中。

对比:Options API vs Composition API

Options API (Vue 2 风格):

export default {
  data() {
    return {
      count: 0,
      todos: []
    }
  },
  methods: {
    increment() {
      this.count++
    },
    fetchTodos() {
      // 获取待办事项
    }
  },
  computed: {
    doubleCount() {
      return this.count * 2
    }
  },
  mounted() {
    this.fetchTodos()
  }
}

Composition API (Vue 3 风格):

import { ref, computed, onMounted } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const todos = ref([])
    
    const doubleCount = computed(() => count.value * 2)
    
    const increment = () => {
      count.value++
    }
    
    const fetchTodos = async () => {
      // 获取待办事项
    }
    
    onMounted(() => {
      fetchTodos()
    })
    
    return {
      count,
      todos,
      doubleCount,
      increment,
      fetchTodos
    }
  }
}

核心 API 详解

1. ref() - 响应式基础类型

ref() 用于创建响应式的基本类型值。

import { ref } from 'vue'

const count = ref(0)

// 访问和修改
console.log(count.value) // 0
count.value = 1

// 在模板中自动解包,不需要 .value

关键点:

  • 在 JavaScript 中需要通过 .value 访问
  • 在模板中自动解包
  • 支持任意类型的值

2. reactive() - 响应式对象

reactive() 用于创建响应式对象。

import { reactive } from 'vue'

const state = reactive({
  count: 0,
  user: {
    name: '张三',
    age: 25
  }
})

// 直接访问,不需要 .value
state.count++
state.user.age = 26

ref vs reactive

  • ref 可以处理任意类型,包括基本类型
  • reactive 只能处理对象类型
  • ref 需要 .valuereactive 直接访问

3. computed() - 计算属性

import { ref, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')

const fullName = computed(() => {
  return firstName.value + lastName.value
})

// 只读计算属性
console.log(fullName.value) // '张三'

// 可写计算属性
const fullNameWritable = computed({
  get: () => firstName.value + lastName.value,
  set: (val) => {
    const [first, last] = val.split(' ')
    firstName.value = first
    lastName.value = last
  }
})

4. watch() 和 watchEffect()

watch() - 精确监听:

import { ref, watch } from 'vue'

const count = ref(0)

// 监听单个值
watch(count, (newVal, oldVal) => {
  console.log(`从 ${oldVal} 变为 ${newVal}`)
})

// 监听多个值
watch([count, firstName], ([newCount, newName]) => {
  console.log('变化:', newCount, newName)
})

// 监听对象属性(深度监听)
const user = reactive({ profile: { name: '张三' } })
watch(
  () => user.profile,
  (newVal) => {
    console.log('profile 变化', newVal)
  },
  { deep: true }
)

watchEffect() - 自动追踪依赖:

import { ref, watchEffect } from 'vue'

const count = ref(0)

watchEffect(() => {
  // 自动追踪 count 的变化
  console.log('count 是:', count.value)
  // 这里不需要指定监听谁,Vue 自动分析依赖
})

5. 生命周期钩子

Composition API 中的生命周期钩子需要在 setup() 中调用:

import { 
  onMounted, 
  onUpdated, 
  onUnmounted,
  onBeforeMount,
  onBeforeUpdate,
  onBeforeUnmount,
  onErrorCaptured
} from 'vue'

export default {
  setup() {
    onMounted(() => {
      console.log('组件已挂载')
    })
    
    onUpdated(() => {
      console.log('组件已更新')
    })
    
    onUnmounted(() => {
      console.log('组件已卸载')
    })
    
    // 错误捕获
    onErrorCaptured((err, instance, info) => {
      console.error('捕获到错误:', err)
      return false // 阻止错误继续传播
    })
  }
}

逻辑复用:组合式函数

Composition API 最大的优势在于逻辑复用。我们可以将相关逻辑封装成组合式函数(Composables)。

示例:useCounter

// composables/useCounter.js
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const double = computed(() => count.value * 2)
  
  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => count.value = initialValue
  
  return {
    count,
    double,
    increment,
    decrement,
    reset
  }
}

使用:

import { useCounter } from './composables/useCounter'

export default {
  setup() {
    const { count, double, increment, decrement } = useCounter(10)
    
    return {
      count,
      double,
      increment,
      decrement
    }
  }
}

示例:useFetch

// composables/useFetch.js
import { ref, onMounted } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const loading = ref(true)
  const error = ref(null)
  
  const fetchData = async () => {
    try {
      loading.value = true
      const response = await fetch(url)
      data.value = await response.json()
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }
  
  onMounted(() => {
    fetchData()
  })
  
  return {
    data,
    loading,
    error,
    refresh: fetchData
  }
}

使用:

import { useFetch } from './composables/useFetch'

export default {
  setup() {
    const { data, loading, error, refresh } = useFetch('/api/users')
    
    return {
      data,
      loading,
      error,
      refresh
    }
  }
}

实际应用场景

1. 表单处理

// composables/useForm.js
import { ref, reactive } from 'vue'

export function useForm(initialValues = {}, validators = {}) {
  const form = reactive({ ...initialValues })
  const errors = reactive({})
  const touched = reactive({})
  
  const validate = (field) => {
    if (validators[field]) {
      const error = validators[field](form[field])
      errors[field] = error
      return !error
    }
    return true
  }
  
  const validateAll = () => {
    return Object.keys(validators).every(validate)
  }
  
  const setField = (field, value) => {
    form[field] = value
    touched[field] = true
  }
  
  return {
    form,
    errors,
    touched,
    validate,
    validateAll,
    setField
  }
}

2. 响应式路由

// composables/useRoute.js
import { ref, computed } from 'vue'
import { useRoute as useVueRoute } from 'vue-router'

export function useRoute() {
  const route = useVueRoute()
  
  const queryParams = computed(() => route.query)
  const params = computed(() => route.params)
  const fullPath = computed(() => route.fullPath)
  
  return {
    route,
    queryParams,
    params,
    fullPath
  }
}

最佳实践

1. 逻辑组织

将相关的逻辑组织在一起:

export default {
  setup() {
    // 状态定义
    const count = ref(0)
    const loading = ref(false)
    
    // 计算属性
    const doubleCount = computed(() => count.value * 2)
    
    // 方法
    const increment = () => count.value++
    
    // 生命周期
    onMounted(() => {
      console.log('mounted')
    })
    
    // 返回
    return {
      count,
      doubleCount,
      increment
    }
  }
}

2. 使用 <script setup>

Vue 3.2+ 引入了 <script setup>,让 Composition API 更加简洁:

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

const count = ref(0)
const doubleCount = computed(() => count.value * 2)

const increment = () => count.value++

onMounted(() => {
  console.log('mounted')
})
</script>

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ doubleCount }}</p>
    <button @click="increment">增加</button>
  </div>
</template>

3. 类型推导

Composition API 对 TypeScript 支持更好:

import { ref } from 'vue'

// 类型自动推导
const count = ref(0) // Ref<number>
const user = ref({ name: '张三', age: 25 }) // Ref<{ name: string, age: number }>

// 显式类型
const count2 = ref<number>(0)
interface User {
  name: string
  age: number
}
const user2 = ref<User>({ name: '张三', age: 25 })

总结

Composition API 为 Vue 3 带来了:

  1. 更好的逻辑组织 - 相关代码放在一起,而不是分散在多个选项中
  2. 更强的逻辑复用 - 通过组合式函数实现代码复用
  3. 更好的 TypeScript 支持 - 类型推导更准确
  4. 更小的打包体积 - 更好的 tree-shaking

虽然学习曲线稍陡,但对于中大型项目来说,Composition API 是更好的选择。建议从简单的 refcomputedwatch 开始,逐步掌握组合式函数的编写技巧。

Markdown 渲染如何穿插自定义组件

在 Vue 3 流式 Markdown 渲染器中实现插件化自定义组件——踩坑全记录

背景

v3-markdown-stream 是一个基于 Vue 3 的高性能 Markdown 流式渲染组件,核心特性是支持 LLM 场景下的增量输出渲染——内容一段一段地追加,页面实时更新,无闪屏、无卡顿。

随着 AI 对话场景的丰富,单纯渲染文本已经不够了。我们希望在 Markdown 流式输出中直接嵌入图表、自定义组件。比如 LLM 返回:

根据数据分析,本月销售情况如下:

[[echarts {"type":"bar","data":[10,20,30,40,50]}]]

从图表可以看出...

渲染器应该识别 [[echarts ...]] 语法,直接在 Markdown 中渲染出 ECharts 图表。

听起来简单,实际开发中踩了一堆坑。本文记录整个开发过程和解决方案。


一、插件系统设计

1.1 核心思路

插件系统的核心流程:

流式内容: [[echarts {"type":"bar","data":[10,20,30]}]]
    ↓ 正则匹配
转换后: <v3md-echarts data-config="..." data-key="..."></v3md-echarts>
    ↓ rehype-raw 解析 HTML
HAST 树中包含自定义标签节点
    ↓ toJsxRuntime 组件映射
渲染为 ECharts Vue 组件

关键设计决策:

  • 正则匹配:用 [[插件名 JSON配置]] 语法,正则 \[\[echarts\s+([\s\S]*?)\]\] 匹配
  • HTML 标签桥接:将匹配结果转换为自定义 HTML 标签,利用已有的 rehype-raw 插件解析
  • 组件映射:在 toJsxRuntimecomponents 参数中注册自定义标签到 Vue 组件的映射

1.2 流式场景的"不完整语法"问题

流式输出时,内容是逐步到达的。[[echarts {"type":"bar","data":[10,20 这样的内容在某一时刻是不完整的——JSON 没闭合、]] 没出现。

第一个坑:不完整的插件语法会导致后续所有 Markdown 解析错乱。

如果正则匹配不到完整的 [[...]],残留的 [ 会被 Markdown 解析器当作链接语法,导致后续内容渲染异常。

解决方案:对不完整的插件语法进行清理,用正则 [[echarts\b[\s\S]*$ 匹配流末尾的不完整语法,将其替换为 loading 占位符(而非直接删除,后面会讲为什么)。

for (const [, plugin] of pluginMap) {
  const incompleteRegex = new RegExp(
    `\\[\\[${escapeRegex(plugin.name)}\\b[\\s\\S]*$`,
    'g'
  );
  result = result.replace(incompleteRegex, () => {
    return `\n\n<div class="v3md-plugin-container"><v3md-loading></v3md-loading></div>\n\n`;
  });
}

二、ECharts 组件的集成

2.1 动态导入

ECharts 体积很大(压缩后约 1MB),不能让所有用户都加载。使用动态 import() 实现:

const initChart = async () => {
  const echarts = await import('echarts');
  chartInstance.value = echarts.init(chartRef.value);
  chartInstance.value.setOption(getOption(props.config));
};

2.2 配置解析——简单模式 vs 完整模式

第二个坑:用户期望的配置方式和 ECharts 原生配置差距很大。

ECharts 原生配置需要写 seriesxAxisyAxis 等,但用户只想写 {"type":"bar","data":[10,20,30]}

解决方案:支持两种模式:

  • 简单模式type + data,自动补全坐标轴等配置
  • 完整模式:直接传 ECharts 的 option,支持所有功能
const getOption = (config) => {
  const { type, data, width, height, ...rest } = config;
  const option = { ...rest };

  if (type && !option.series) {
    option.series = [{ type, data: data || [] }];
  }

  if (!option.xAxis && !option.yAxis && type === 'bar') {
    option.xAxis = { type: 'category', data: data.map((_, i) => `${i + 1}`) };
    option.yAxis = { type: 'value' };
  }
  // ...
  return option;
};

三、闪烁问题——最大的坑

这是整个开发过程中最棘手的问题。流式追加内容时,ECharts 图表会不断闪烁(消失再出现),体验极差。

3.1 原因分析

经过深入排查,闪烁有三层原因

原因一:CSS 通配符动画
* {
  animation: fade-in 0.6s ease-in-out;
}

这个通配符选择器让所有元素每次 DOM 更新都重新触发淡入动画。Vue 虽然复用了 DOM 节点,但 CSS 动画会在元素属性变化时重新触发。

修复:排除插件容器及其子元素:

*:not(.v3md-plugin-container):not(.v3md-plugin-container *) {
  animation: fade-in 0.6s ease-in-out;
}
原因二:组件映射引用不稳定

toJsxRuntimecomponents 参数每次渲染都是新对象。更严重的是,如果 getComponentMappings() 每次返回新的组件定义,Vue 会认为是不同的组件,直接销毁重建。

// ❌ 每次调用都创建新的 defineComponent
function getComponentMappings() {
  const mappings = {};
  for (const [, plugin] of pluginMap) {
    mappings[plugin.tagName] = createPluginWrapper(plugin); // 每次都是新组件!
  }
  return mappings;
}

修复:缓存组件映射:

let cachedMappings = null;

function getComponentMappings() {
  if (cachedMappings) return cachedMappings;
  cachedMappings = {};
  for (const [, plugin] of pluginMap) {
    cachedMappings[plugin.tagName] = createPluginWrapper(plugin);
  }
  return cachedMappings;
}
原因三:Config 对象引用每次都是新的

这是最隐蔽的问题。流式追加内容时,props.node 引用每次都变(因为 HAST 树重建),即使 data-config 字符串完全相同,watch 也会触发,生成新的 config 对象。ECharts 组件的 deep: true watch 检测到"新"对象,就调用 setOption 重绘。

// ❌ 即使 config 内容相同,对象引用不同就会触发
watch(() => props.config, (newConfig) => {
  chartInstance.value.setOption(getOption(newConfig));
}, { deep: true });

修复:在两层都做字符串比较去重:

层一——Plugin Wrapper:比较原始 data-config 字符串,相同则不更新 configRef

let lastRawConfig = '';

watch(() => props.node, (node) => {
  const rawConfig = node.properties?.['data-config'] || '';
  if (rawConfig === lastRawConfig) return;  // 字符串相同,跳过
  lastRawConfig = rawConfig;
  configRef.value = JSON.parse(decodeURIComponent(rawConfig));
});

层二——ECharts 组件:比较 JSON 序列化结果,相同则跳过 setOption

let lastConfigJson = '';

const updateChart = (newConfig) => {
  const newJson = JSON.stringify(newConfig);
  if (newJson === lastConfigJson) return;  // 内容相同,跳过
  lastConfigJson = newJson;
  chartInstance.value.setOption(getOption(newConfig));
};

3.2 闪烁修复总结

层级 问题 修复
CSS * 通配符动画影响插件容器 :not() 排除
组件映射 每次返回新组件定义 缓存 cachedMappings
Config 传递 node 引用变化触发不必要的更新 字符串比较去重
ECharts 更新 deep: true watch 过于敏感 JSON 序列化比较去重

四、流式碎片的 Loading 状态

4.1 从"删除"到"Loading"

最初处理流式碎片的方式是直接删除:不完整的图片删掉、不完整的数学公式删掉、不完整的插件语法删掉。

问题:用户看到内容突然消失又出现,体验很差。比如图片 URL 传到一半被删掉,传完整后又突然出现,视觉上就是"闪一下"。

改进:将碎片内容替换为 loading 动画,内容完整后自动替换为实际渲染结果。

4.2 三种碎片场景

碎片类型 示例 处理方式
不完整图片 ![alt](http://incom 替换为 <v3md-loading>
未闭合公式 $$ x^2 + 删除未闭合部分 + 替换为 <v3md-loading>
不完整插件 [[echarts {"type": 替换为 <v3md-loading>

4.3 Loading 组件的虚拟 DOM 实现

Loading 动画使用 three-body 旋转点动画,需要用虚拟 DOM 实现(因为整个渲染管线都是虚拟 DOM):

const V3mdLoading = defineComponent({
  name: 'V3mdLoading',
  setup() {
    return () =>
      h('div', { class: 'v3md-loading' }, [
        h('div', { class: 'three-body' }, [
          h('div', { class: 'three-body__dot' }),
          h('div', { class: 'three-body__dot' }),
          h('div', { class: 'three-body__dot' }),
        ]),
      ]);
  },
});

第四个坑:<v3md-loading> 标签被 Markdown 解析器包裹在 <p> 标签内。

自定义标签在 Markdown 中默认被当作行内 HTML,被 <p> 包裹。流式追加时 <p> 的结构变化导致 VNode 树不稳定。

修复:在替换时前后加空行,并用 <div class="v3md-plugin-container"> 包裹,确保被解析为块级元素:

return `\n\n<div class="v3md-plugin-container"><v3md-loading></v3md-loading></div>\n\n`;

五、插件默认内置

5.1 用户体验优化

最初的设计要求用户手动引入和配置:

<script setup>
import { createPluginRegistry } from 'v3-markdown-stream'
import { echartsPlugin } from './echarts-plugin.js'
const registry = createPluginRegistry([echartsPlugin])
</script>

<template>
  <MarkdownRender :pluginRegistry="registry" />
</template>

这对用户来说太繁琐了。ECharts 是最常用的图表库,应该开箱即用。

修复:在 createPluginRegistry 中默认包含 echarts 插件:

import { echartsPlugin } from './echarts-plugin.js';
const DEFAULT_PLUGINS = [echartsPlugin];

export function createPluginRegistry(plugins = []) {
  const allPlugins = [...DEFAULT_PLUGINS, ...plugins];
  // ...
}

markdownRender.vue 中自动创建默认 registry:

const defaultRegistry = createPluginRegistry();

模板中 fallback:

<VueMarkdownStreamRender :pluginRegistry="pluginRegistry || defaultRegistry" />

现在用户只需:

<MarkdownRender :markInfo="content" />

ECharts 图表就能直接渲染。


六、ref 标签点击事件

Markdown 中使用 <ref>[3]</ref> 标注引用,点击时需要将引用编号上报给父组件。

6.1 组件映射

和 ECharts 一样,通过 toJsxRuntimecomponents 映射将 ref 标签映射到 Vue 组件:

const baseComponents = {
  table: TableCode,
  pre: PreCode,
  ref: RefTag,
};

6.2 事件传递——provide/inject 模式

第五个坑:toJsxRuntime 生成的 VNode 树中,组件无法直接 emit 事件到上层。

因为 RefTag 组件不是 markdownRender.vue 的直接子组件,中间隔了 markdown-parse.js 和 VNode 树多层嵌套,emit 事件无法冒泡。

解决方案:使用 provide/inject 跨层级传递事件回调:

// markdown-parse.js - provide
provide(REF_CLICK_KEY, (numbers) => {
  if (props.onRefClick) {
    props.onRefClick(numbers);
  }
});

// ref-tag.js - inject
const onRefClick = inject(REF_CLICK_KEY, null);

// 点击时调用
onClick: (e) => {
  if (onRefClick && numbers.length > 0) {
    onRefClick(numbers);
  }
}

最终通过 markdownRender.vueemit('refClick', numbers) 暴露给父组件。

6.3 正则提取引用编号

const extractRefNumbers = (node) => {
  const text = getTextContent(node);  // 递归提取所有文本子节点
  const match = text.match(/\[(\d+(?:\s*,\s*\d+)*)\]/);
  if (match) {
    return match[1].split(/\s*,\s*/).map(Number);
  }
  return [];
};

支持 [3][1,2,3][1, 2, 3] 等格式。


七、整体架构

┌─────────────────────────────────────────────────────┐
                    MarkdownRender                     
  props: markInfo, themeColor, pluginRegistry         
  emit: refClick                                      
  ┌─────────────────────────────────────────────────┐ 
              markdown-parse.js                      
    ┌───────────┐  ┌──────────┐  ┌──────────────┐  
     stripBroken│→  transform │→    unified      
      Images       Markdown      processor     
     (loading)     (plugins)     (HAST)        
    └───────────┘  └──────────┘  └──────┬───────┘  
                                                   
                                ┌────────▼────────┐│ 
                                  toJsxRuntime   ││ 
                                  components:    ││ 
                                  ┌───────────┐  ││ 
                                   table       ││ 
                                   pre         ││ 
                                   ref         ││ 
                                   v3md-*      ││ 
                                   loading     ││ 
                                  └───────────┘  ││ 
                                └─────────────────┘│ 
  └─────────────────────────────────────────────────┘ 
└─────────────────────────────────────────────────────┘

总结

这次插件化改造踩了五个主要坑:

  1. 不完整语法导致解析错乱 → 正则清理 + loading 占位
  2. ECharts 配置门槛高 → 简单模式自动补全
  3. 流式渲染闪烁 → CSS 排除 + 组件缓存 + Config 去重(三层修复)
  4. 自定义标签被 <p> 包裹 → 块级 div 包裹 + 空行隔离
  5. VNode 树中事件无法冒泡 → provide/inject 跨层级传递

最深刻的教训是:流式渲染场景下,任何"引用不稳定"都会被放大。普通场景中组件重建一次可能无感,但流式场景下每秒更新数十次,组件反复销毁重建就变成了闪烁。核心策略是:能缓存就缓存,能比较就比较,能跳过就跳过

从零到一:用 Vue3 + Kimi 大模型打造「拍照记单词」AI 应用

从零到一:用 Vue3 + Kimi 大模型打造「拍照记单词」AI 应用

本文适合有一定 Vue3 基础、想了解如何将大模型 API 集成到前端项目的开发者。完整项目已开源,文末附链接。

前言

在 AI 时代,"一个人的公司"(OPC)正在成为可能。本文将带你从零搭建一个 拍照记单词 的前端 AI 应用——用户拍一张照片,AI 自动识别图片内容并生成一个英文单词、例句和发音。

这个项目的核心价值在于:它不是一个 Demo,而是一个可以落地的产品原型。你会学到:

  • 如何用 Vue3 Composition API 组织复杂业务逻辑
  • 如何调用多模态大模型(Kimi Vision)解析图片
  • 如何集成 TTS 语音合成
  • 如何设计一个对用户友好的 Prompt

一、项目架构总览

vue3-ts-cameraword/
├── src/
│   ├── App.vue                 # 主页面,核心业务逻辑
│   ├── components/
│   │   └── PictureCard.vue     # 拍照卡片组件
│   ├── lib/
│   │   └── audio.ts            # TTS 语音合成模块
│   └── main.ts                 # 入口文件
├── .env.local                  # 环境变量(API Key 等)
└── vite.config.ts              # Vite 配置

技术栈:Vue3 + TypeScript + Vite + Kimi Vision API + 火山引擎 TTS


二、核心功能实现

2.1 图片上传:FileReader 的妙用

传统文件上传需要后端配合,但多模态大模型可以直接接收 Base64 编码的图片。我们用 FileReader 在前端完成图片转码:

// PictureCard.vue
const updateImageData = async (e: Event): Promise<any> => {
    const file = (e.target as HTMLInputElement).files?.[0];
    if (!file) return;

    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.readAsDataURL(file); // 转为 Base64
        reader.onload = () => {
            const data = reader.result as string;
            imgPreview.value = data;        // 本地预览
            emit('updateImage', data);      // 传给父组件
            resolve(data);
        };
        reader.onerror = (error) => reject(error);
    });
};

关键点:

  • readAsDataURL() 将文件转为 data:image/png;base64,... 格式的字符串
  • 这个字符串可以直接作为 <img>src 实现预览
  • 同时可以直接传给大模型的 image_url 字段

2.2 调用 Kimi Vision:多模态 API 实战

这是整个项目的核心。Kimi 的 moonshot-v1-8k-vision-preview 模型支持图片+文字的混合输入:

// App.vue
const update = async (imageDate: string) => {
    const endpoint = import.meta.env.VITE_KIMI_API_ENDPOINT + '/chat/completions';
    const headers = {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${import.meta.env.VITE_KIMI_API_KEY}`
    };

    word.value = '分析中...';

    const response = await fetch(endpoint, {
        method: 'POST',
        headers,
        body: JSON.stringify({
            model: 'moonshot-v1-8k-vision-preview',
            messages: [{
                role: 'user',
                content: [
                    {
                        type: 'image_url',
                        image_url: { url: imageDate }  // Base64 图片
                    },
                    {
                        type: 'text',
                        text: userPrompt                // 文字指令
                    }
                ]
            }],
            stream: false
        })
    });

    const data = await response.json();
    const replyData = JSON.parse(data.choices[0].message.content);
    // 处理返回数据...
};

这里的 content 是一个数组,可以同时包含图片和文字。这是多模态 API 的标准用法。

2.3 Prompt 设计:决定产品质量的关键

Prompt 是 AI 产品的灵魂。一个好的 Prompt 需要:

  1. 清晰的指令:告诉模型你要什么
  2. 明确的输出格式:JSON 格式便于前端解析
  3. 约束条件:限制词汇难度、输出长度等
const userPrompt = `
  分析图片内容,找出最能描述图片的一个英文单词,尽量选择更简单的A1~A2的词汇。

  返回JSON 数据:
  {
    "image_discription": "图片描述",
    "representative_word": "图片代表的英文单词",
    "example_sentence": "结合英文单词和图片描述,给出一个简单的例句",
    "explaination": "结合图片解释英文单词,段落以Look at ...开头,
                    将段落分句,每一句单独一行,
                    解释的最后给一个日常生活有关的问句",
    "explanation_replys": ["根据explaination给出的回复1",
                          "根据explaination给出的回复2"]
  }
`;

设计要点:

  • A1~A2 级别:控制词汇难度,适合初学者
  • JSON 格式OutputParser 的思想,让返回数据结构化,便于业务处理
  • Look at ... 开头:引导模型用"看图说话"的方式解释,更生动
  • 问句结尾:制造对话感,增强学习互动性

2.4 TTS 语音合成:让单词"说出来"

学英语离不开发音。我们集成火山引擎的 TTS 服务,将例句转为语音:

// lib/audio.ts
export const generateAudio = async (text: string) => {
    const endpoint = '/tts/api/v1/tts';
    const headers = {
        'Content-Type': 'application/json',
        'Authorization': `Bearer;${token}`
    };

    const payload = {
        app: { appid: appId, token, cluster: clusterId },
        user: { uid: 'bearbobo' },
        audio: {
            voice_type: voiceName,    // 音色:en_female_anna_mars_bigtts
            encoding: 'ogg_opus',     // 音频编码格式
            speed_ratio: 1.0,         // 语速
            emotion: 'happy',         // 情绪
        },
        request: {
            reqid: Math.random().toString(36).substring(7),
            text,                     // 要合成的文本
            text_type: 'plain',
            operation: 'query',
        },
    };

    const res = await fetch(endpoint, {
        method: 'POST',
        headers,
        body: JSON.stringify(payload)
    });

    const data = await res.json();
    return createBlobURL(data.data);  // 转为可播放的 URL
};

Base64 转 Blob URL 的工具函数:

function createBlobURL(base64AudioData: string): string {
    const byteArrays: number[] = [];
    const byteCharacters = atob(base64AudioData);  // 解码 Base64

    for (let offset = 0; offset < byteCharacters.length; offset++) {
        byteArrays.push(byteCharacters.charCodeAt(offset));
    }

    const audioBlob = new Blob([new Uint8Array(byteArrays)], {
        type: 'audio/mp3'
    });

    return URL.createObjectURL(audioBlob);  // 生成临时播放 URL
}

播放逻辑很简单:

// PictureCard.vue
const playAudio = () => {
    const audio = new Audio(props.audio);
    audio.play();
};

三、Vite 代理配置:解决跨域问题

前端直接调用第三方 API 会遇到跨域。用 Vite 的 server.proxy 解决:

// vite.config.ts
export default defineConfig({
    plugins: [vue()],
    server: {
        host: '0.0.0.0',           // 允许局域网访问
        proxy: {
            '/tts': {
                target: 'https://openspeech.bytedance.com',
                changeOrigin: true,
                rewrite: path => path.replace(/^\/tts/, ''),
            }
        },
    },
});
  • host: '0.0.0.0':让手机等设备也能访问开发服务器
  • /tts 代理:将 /tts/api/v1/tts 转发到火山引擎的 API

四、无障碍设计:被忽略的细节

这个项目有一个亮点:支持读屏器的无障碍访问

传统的 <input type="file"> 样式很难控制。我们的做法是:

<!-- 隐藏原生 input,用 label 触发 -->
<input type="file" id="selecteImage" class="input"
       accept="image/*" @change="updateImageData">
<label for="selecteImage" class="upload">
    <img :src="imgPreview" alt="camera" class="img"/>
</label>
.input {
    display: none;  /* 隐藏原生控件 */
}
  • for="selecteImage" 关联 id,点击 label 等同于点击 input
  • accept="image/*" 限制只能选择图片
  • 读屏器可以通过 label 的文本识别按钮用途

效果

image.png

image.png

五、项目总结与思考

学到了什么

  1. 多模态 API 的调用方式content 字段是数组,图片用 Base64 编码传入
  2. Prompt 工程:JSON 输出格式、难度约束、引导性描述
  3. 前端音频处理:Base64 → Blob → ObjectURL 的完整链路
  4. Vite 代理:一行配置解决跨域

可以改进的方向

  • 加入流式输出stream: true),让分析过程可视化
  • 增加单词本功能,收藏学过的单词
  • 接入语音识别,支持跟读打分
  • IndexedDB 本地存储学习记录

六、环境配置

创建 .env.local 文件:

VITE_KIMI_API_KEY=sk-xxxxx          # Kimi API Key
VITE_KIMI_API_ENDPOINT=https://api.moonshot.cn/v1

VITE_AUDIO_APP_ID=xxxxx             # 火山引擎 TTS 配置
VITE_AUDIO_ACCESS_TOKEN=xxxxx
VITE_AUDIO_CLUSTER_ID=volcano_tts
VITE_AUDIO_VOICE_NAME=en_female_anna_mars_bigtts

启动项目:

npm install
npm run dev

写在最后

这个项目虽然代码量不大,但覆盖了前端 AI 应用的核心链路:图片输入 → 多模态理解 → 结构化输出 → 语音合成

AI 时代,前端工程师的价值不只是写页面,更是用 AI 能力重新定义产品体验。希望这篇文章能给你一些启发。

项目地址:[project/capture_word /lesson_zp - 码云 - 开源中国] 欢迎 Star 和 PR!

脚手架搭建项目框架(create-vite、vue-cli、create-vue、quasar-cli)

脚手架脚手架搭建项目框架

一. create-vite

npm create vite@latest(yarn create vite)安装脚手架命令

二. vue-cli

npm i -g @vue-cli安装脚手架命令

vue create project1创建项目

三. create-vue(vue官方的项目脚手架工具,内置了vite构建工具)项目开发中使用的脚手架

1.技术栈:

Create-vue脚手架 + Vite构建工具 + vue3组合式API (vue3选项式API)+ typescript + vue-router + pinia状态管理 (vuex状态管理)+ axios网络库 + vant3 UI组件库(element-plus UI组件库) + eslint + pretter + sass

2.脚手架搭建项目框架步骤:

1) npm init vue@latest 安装脚手架命令。

根据预设生成相应的配置文件(选择ts、vue-router、pinia、eslint、pretter)。

image.png

2) pinia配置:

npm i pinia-plugin-persist -S 安装pinia持久化存储插件。

创建stores文件夹→index.ts(创建pinia根存储,集成插件)

import { createPinia } from 'pinia'
import piniaPluginPersist from 'pinia-plugin-persist'//引入pinia持久化存储插件

const storeRoot = createPinia()
storeRoot.use(piniaPluginPersist)//集成插件
export default storeRoot

main.js入口文件(集成pinia)

import { createApp } from "vue";
import router from "./router";
import store from "./store";
import App from "./App.vue";
import 'normalize.css' // 重置样式

const app = createApp(App)
app.use(router);
app.use(store);
app.mount("#app");

stores文件夹→user.ts(定义store存储对象,持久化存储增加persist选项)

import {defineStore} from 'pinia'
/**
 * 定义名为useUserStore的存储对象
 *  defineStore方法
 *   第一个参数: 模块名称是唯一的
 *   第二个参数: 选项对象  state, actions, getters
 *                        data    methods   computed
 */
export const useUserStore = defineStore('user',{
    state(){
        return {
            account:null // 账户数据 {name,nick,password}  
        }
    },
    actions:{
        // 具体业务逻辑,可以是同步或异步操作
        saveAccount(account){
            this.account = account
        }
    },
    getters:{
        //getters中定义的方法名/计算属性名不能与state相同
        // userAccount:state => {
        //     return state.account
        // }//定义方式1
        userAccount(){
            return this.account
        }//定义方式2
    },
    persist: {//持久化存储
        enabledtrue,
        strategies: [
            {
                key: user,
                storagelocalStorage,
                paths: ['account'],
            },
        ],
    },
})

3)Sass、axios网络库、UI组件库需手动安装集成。状态管理vuex也需要手动安装集成。

npm i normalize.css -S 安装样式重置库(兼容浏览器)。main.ts中引入import'normalize.css'
npm i sass -d 安装sass(css预处理器,开发环境用)。
npm install axios -s 安装网络库axios(前后端数据交互)。

创建utils文件夹→request.ts中配置(创建axios实例,封装请求/响应拦截器)

import axios from 'axios'
import { Toast } from 'vant';
// 服务根地址
// export const baseURL = 'http://10.7.163.165:8089'  // 开发环境
/**
 * 创建axios实例
 *   封装baseURL
 */
const axiosInstance = axios.create({
    baseURL, // 服务根地址
    timeout: 3000, // 超时时间
})
/**
 * 请求拦截器
 */
axiosInstance.interceptors.request.use(
    config => {
        const token = localStorage.getItem('TOKEN')
        if(token){
            config.headers['Authorization'] = token
        }
        return config
    },
    error => {
        return Promise.reject(error)
    }
)
/**
 * 响应拦截器
 */
axiosInstance.interceptors.response.use(
    response => {
        return response.data
    },
    error => {
        const { response } = error
        if (response) {
            const status = response.status
            switch (status) {
                case 404:
                    Toast('资源不存在 404')
                    break
                case 401:
                    Toast('Unauthorized 身份验证凭证缺失!')
                    break
                case 403:
                    Toast('403 Forbidden - 拒绝访问!')
                    break
                case 500:
                    Toast('服务器出错')
                    break
                default:
                    Toast('出现异想不到的错误!')
                    break
            }
        }else {
            // 说明服务器连结果都没有返回,可能的原因有两种:
            /**
             * 1. 服务器崩掉了
             * 2. 前端客户端断网状态
             */
            if (!window.navigator.onLine) {
                // 判断为断网,可以跳转到断网页面
                Toast('网络不可用,请检查您的网络连接!')
                return
            } else {
                Toast('连接服务端出错!' + error?.message)
                return Promise.reject(error)
            }
        }
        return Promise.reject(error)
    }
)
export default axiosInstance
vant组件库安装与配置
  • npm i vant@latest-v3 安装vant组件库
  • npm i unplugin-vue-components -D 安装vant按需引入插件。vite.config.ts中引入集成
  • npm i postcss-pxtorem -D安装移动端适配插件,将px单位转化为rem单位。vite.config.ts中引入集成
  • npm i amfe-flexible -D安装移动端适配插件,适配不同屏幕尺寸。main.ts中引入

vite.config.ts

import { fileURLToPath, URLfrom 'node:url'

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

import Components from 'unplugin-vue-components/vite'//引入按需引入插件
import { VantResolverfrom 'unplugin-vue-components/resolvers'//引入按需引入插件

import postCssPxToRem from 'postcss-pxtorem'//引入移动端适配插件

import { viteMockServe } from 'vite-plugin-mock'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [VantResolver()],//集成按需引入插件
    }),
    viteMockServe({//集成mock模拟接口数据插件
      // 更多配置见最下方
      supportTstrue,
      loggerfalse,
      mockPath'./mock/', // 文件位置
    }),
  ],
  resolve: {
    alias: {
      '@'fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  css:{
    postcss:{
      plugins:[
        postCssPxToRem({//自适应,px转rem
          rootValue:75,//换算的基数(设计图750的根字体为75)
          propList:['*']//需要转换的属性,*代表全部
        })
      ]
    }
  },
  server:{//代理服务器
    proxy: {
      '/api': {
        target'http://124.71.63.13:8088/',
        changeOrigintrue,
        // rewrite: path => path.replace(/^\/api/, '')
      }
    }
  },
})

main.ts

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './stores'
import 'normalize.css' // 重置样式

// 导入vant函数组件样式
import 'vant/es/toast/style'
import 'vant/es/dialog/style'
import 'vant/es/notify/style'
import 'vant/es/image-preview/style'

//引入amfe-flexible屏幕适配插件
import 'amfe-flexible'

const app = createApp(App)
app.use(store)
app.use(router)

app.mount('#app')
elementplus组件库安装与配置
  • npm install element-plus --save安装elementplus组件库
  • npm install -d unplugin-vue-components unplugin-auto-import安装两个插件自动按需引入

vite.config.js集成插件

import { fileURLToPath, URL } from 'node:url'

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

import AutoImport from 'unplugin-auto-import/vite'//引入插件
import Components from 'unplugin-vue-components/vite'//引入插件
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'//引入插件

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    AutoImport({//集成插件
        resolvers: [ElementPlusResolver()],
    }),
    Components({//集成插件
        resolvers: [ElementPlusResolver()],
    }),
  ],
  resolve: {
    alias: {
      '@'fileURLToPath(new URL('./src', import.meta.url))
    }
  },
})

main.js引入elementplus样式,否则可能出现使用组件没效果。

import { createApp } from "vue";
import router from "./router";
import store from "./store";
import 'element-plus/dist/index.css';//引入elementplus样式
import App from "./App.vue";

import ElementPlus from 'element-plus';//完整引入elementplus,文件大小会很大。可以考虑按需引入

const app = createApp(App);
app.use(router);
app.use(store);

app.use(ElementPlus);//完整引入elementplus,文件大小会很大。可以考虑按需引入
app.mount("#app");
状态管理vuex安装与配置
  • npm install vuex@next(4.0.2) --save安装vuex插件
  • npm i vuex-persistedstate -s安装vuex持久化存储插件

main.js引入集成到vue

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store  from './store'

const app = createApp(App)
app.use(router)
app.use(store)
app.mount('#app')

创建story文件夹→index.js文件集成持久化存储插件

import { createStore } from 'vuex'
import createPersistedState from 'vuex-persistedstate'

const store = createStore({
    // 集成持久化存储插件
    plugins:[createPersistedState({
        storage:sessionStorage,
        key:'storekey'
    })]
    state: {//状态数据
        count0
    },
    mutations: {//操作state状态。第一个参数:state对象,第二个参数:外部传入参数
        PLUS(state) {//方法
            // state.count = num
            state.count++
        },
    },
    /**
     * 定义操作mutations方法的方法
     * 供外部组件调用
     *   $store.dispatch('')
     *    1. 单向数流操作方式,保存状态数据以预期方式改变
     *    2. actions 异步操作
     *       mutations 同步操作
     */
    actions: {//操作mutations中的方法
        // plus(context) {//方法
        //     context.commit('PLUS')
        // }
        plus({ commit }) {//解构
            commit('PLUS')
        },
        chs({ commit },num) {//传参
            commit('CHS',num)
        }
    },
    getters: {//获取值,类似计算属性
        num(state) => { return state.count }
        // num: state => state.count
    }
})
export default store

3.解决引入vue组件ts报错(因为ts不识别.vue文件,需在env.d.ts中声明)

env.d.ts

// <reference types="vite/client" />
declare module '*.vue' {
    import { DefineComponent } from 'vue'
    const componentDefineComponent<{}, {}, any>
    export default component
}

4.目录结构介绍

image.png

  • npm install(npm i)下载依赖
  • npm run dev运行

5.打包

  • 将vue文件编译成浏览器能识别的js/html/css文件、ts编译成js文件、scss编译成css文件,压缩处理。
  • 编译后的文件存放在dist目录下(与src同级),都是html/css/js文件,将其部署到服务器上用户就可以用了。

接口环境配置: 开发环境(测试数据)/生产环境(用户使用的数据)

创建utils文件夹→request.ts中配置(创建axios实例,封装请求/响应拦截器,配置接口环境)

解决跨域问题走代理服务器:baseUrl地址为当前客户端地址,vite.config.ts配置代理服务器,代理服务器地址为目标地址(一般指测试地址/线上服务器地址)。

import axios from 'axios'
import { Toastfrom 'vant'

// 服务根地址
// export const baseURL = 'http://10.7.163.165:8089'  // 开发环境

// 生产环境 如果服务端没做跨域处理,使用代理服务器
// 走代理服务器,baseURL 地址配置为与当前客户端地址相同
export let baseURL = 'http://10.7.163.165:8089'  // 开发环境
/**
 * process.env.NODE_ENV
 *    production 生产环境
 *        npm run build
 *    development  开发环境
 *        npm run dev
 */
switch (process.env.NODE_ENV) {
    case 'production':
        baseURL = 'http://124.71.63.13'  // 生产环境
        break
    case 'development':
        baseURL = 'http://10.7.163.165:8089' // 开发环境
        break
}
/**
 * 创建axios实例
 *   封装baseURL
 */
const axiosInstance = axios.create({
    baseURL, // 服务根地址
    timeout3000, // 超时时间
})
/**
 * 请求拦截器
 */
axiosInstance.interceptors.request.use(
    config => {
        const token = localStorage.getItem('TOKEN')
        if (token) {
            config.headers['Authorization'] = token
        }
        return config
    },
    error => {
        return Promise.reject(error)
    }
)
/**
 * 响应拦截器
 */
axiosInstance.interceptors.response.use(
    response => {
        return response.data
    },
    error => {
        const { response } = error
        if (response) {
            const status = response.status
            switch (status) {
                case 404:
                    Toast('资源不存在 404')
                    break
                case 401:
                    Toast('Unauthorized 身份验证凭证缺失!')
                    break
                case 403:
                    Toast('403 Forbidden - 拒绝访问!')
                    break
                case 500:
                    Toast('服务器出错')
                    break
                default:
                    Toast('出现异想不到的错误!')
                    break
            }
        } else {
            // 说明服务器连结果都没有返回,可能的原因有两种:
            /**
             * 1. 服务器崩掉了
             * 2. 前端客户端断网状态
             */
            if (!window.navigator.onLine) {
                // 判断为断网,可以跳转到断网页面
                Toast('网络不可用,请检查您的网络连接!')
                return
            } else {
                Toast('连接服务端出错!' + error?.message)
                return Promise.reject(error)
            }
        }
        return Promise.reject(error)
    }
)
export default axiosInstance

tsconfig.json(解决request.ts中环境配置时process报错)

{
    "extends": "@vue/tsconfig/tsconfig.web.json",
    "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
    "compilerOptions": {
        "baseUrl": ".",
        "paths": {
            "@/*": ["./src/*"]
        },
        // 解决process.env.NODE_ENV报错,
        // 1.安装npm i @types/node -S
        // 配置"types": ["node"]
        "types": ["node"]
    },
    "references": [
        {
            "path": "./tsconfig.config.json"
        }
    ]
}

vite.config.ts配置代理服务器

import { fileURLToPath, URLfrom 'node:url'

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

import Components from 'unplugin-vue-components/vite'//引入按需引入插件
import { VantResolverfrom 'unplugin-vue-components/resolvers'//引入按需引入插件

import postCssPxToRem from 'postcss-pxtorem'//引入移动端适配插件

import { viteMockServe } from 'vite-plugin-mock'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [VantResolver()],//集成按需引入插件
    }),
    viteMockServe({//集成mock模拟接口数据插件
      // 更多配置见最下方
      supportTstrue,
      loggerfalse,
      mockPath'./mock/', // 文件位置
    }),
  ],

  resolve: {
    alias: {
      '@'fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  css:{
    postcss:{
      plugins:[
        postCssPxToRem({//自适应,px转rem
          rootValue:75,//换算的基数(设计图750的根字体为75)
          propList:['*']//需要转换的属性,*代表全部
        })
      ]
    }
  },
  server:{//代理服务器
    proxy: {
      '/api': {
        target'http://124.71.63.13:8088/',//目标地址
        changeOrigintrue,//是否跨域
//这里理解成用'/api'代替target里面的地址,比如我要调用'http://40.00.100.100:3002/user/add',直接写'/api/user/add'即可
        rewrite(path) => path.replace(/^**\/** api/, ''),
      }
    }
  },
})

npm run build打包

四. quasar-cli项目开发中使用的脚手架

关于quasar要求:

  • Node 12+用于Quasar CLI与Webpack,Node 14+用于Quasar CLI与Vite。
  • Yarn v1(强烈推荐),PNPM,或NPM。

npm i -g @quasar/cli 安装脚手架命令

npm init quasar 初始化quasar根据预设生成相应的配置文件

image.png

此时回车,会生成项目文件和目录

image.png

提示安装项目依赖,选择yes回车

image.png

quasar dev(npm run dev)运行

mock模拟后端,生成伪数据接口

官网:github.com/nuysoft/Moc…

1. 安装

npm i mockjs vite-plugin-mock 安装 mockjs和 vite-plugin-mock插件

2. vite.config.ts中集成插件

import { fileURLToPath, URLfrom 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

import Components from 'unplugin-vue-components/vite'//引入按需引入vant插件

import { VantResolverfrom 'unplugin-vue-components/resolvers'

import { viteMockServe } from 'vite-plugin-mock'//引入mock插件

// https://vitejs.dev/config/

export default defineConfig({

  plugins: [

    vue(),

    Components({

      resolvers: [VantResolver()],

    }),

    viteMockServe({//配置mock插件

      // 更多配置见最下方

      supportTstrue,

      loggerfalse,

      mockPath'./mock/', // 文件位置

    }),

  ],

  resolve: {

    alias: {

      '@'fileURLToPath(new URL('./src', import.meta.url))

    }

  }

})

3. mock文件夹mock接口数据

scr同级创建mock文件夹→account.ts(模块拆分,post请求config.body取参)


const accountList = []

/**

 * 用户注册

 */

export const mockRegister = {

    url:'/mock/account/register',

    method:'post',

    response:(config)=>{

        // 注册业务逻辑

        // 1. 获取用户名和密码

        const {username,password,headerimg} = config.body

        // 2. 保存用户信息

        // localStorage.setItem('USER',JSON.stringify({username,password,headerimg}))

        const user = {username,password,headerimg}

        accountList.push(user)

        // 3. 响应内容

        return {

            resultCode:1,

            resultInfo:'注册成功'

        }

    }

}

 

/**

 * 用户登录

 */

export const mockLogin = {

    url:'/mock/account/login',

    method:'post',

    response:(config)=>{

        // 登录业务逻辑

        // 1. 获取用户名和密码

        const {username,password,headerimg} = config.body

        // 2. 判断用户是否注册

        // let userstr = localStorage.getItem('USER')

        // let user = userstr? JSON.parse(userstr):''

        const user = accountList.find(item=>item.username==username && item.password==password)

        if(user){

            return {

                resultCode:1,

                resultInfo: {

                        username,

                        password,

                        headerimg

                }

            }

        }else{

            return {

                resultCode:-1,

                resultInof:'账户出错'

            }

        }

    }

}

scr同级创建mock文件夹→good.ts(模块拆分,get请求config.query取参)

import { goodsListData, bannerListData } from './data/goodsData'

/**

 * 商品列表

 */

export const goodsList = {

    url'/mock/goods/list',

    method'get',

    response(config:any) => {

        //get请求参数

        let { pageNo, pageSize } = config.query

        pageNo = pageNo || 1

        pageSize = pageSize || 10

        console.log('pageNo ', pageNo, ' pageSize :', pageSize)

        // 分页

        const startIndex = (pageNo - 1) * pageSize

        const endIndex = pageNo * pageSize

        const list = goodsListData.slice(startIndex, endIndex)

        if(list.length>0){

            return {

                resultCode1,

                resultInfo: {

                    list,

                },

            }

        }else{

            return {

                resultCode:-1,

                resultInfo:'没有数据'

            }

        }

    },

}

 

/**

 *  banner轮播

 */

export const bannerList = {

    url'/mock/banner',

    method'get',

    response() => {

        return {

            resultCode1,

            resultInfo: {

                list: bannerListData,

            },

        }

    },

}

scr同级创建mock文件夹→data文件夹→goodsData.ts(模块拆分,存放数据)


export const goodsListData = [

    {

        id49,

        shop'【苏宁生鲜】鲜美来东海带鱼600g 海鲜水产 鲜活冷冻',

        picture:

            'https://image1.suning.cn/uimg/b2c/newcatentries/0010128947-000000000614428161_1_800x800.jpg',

        product'【苏宁生鲜】鲜美来东海带鱼600g 海鲜水产 鲜活冷冻',

        price18.88,

        oldprice69,

        putaway1,

        detailnull,

        categoryname'川湘菜',

        categoryId3,

    },

    {

        id48,

        shop'【苏宁生鲜】恒都巴西牛腩块1kg 进口牛肉 精选肉类',

        picture:

            'https://image1.suning.cn/uimg/b2c/newcatentries/0010128947-000000000614167327_1_800x800.jpg',

        product'【苏宁生鲜】恒都巴西牛腩块1kg 进口牛肉 精选肉类',

        price18.98,

        oldprice49,

        putaway1,

        detailnull,

        categoryname'川湘菜',

        categoryId3,

    },

    {

        id45,

        shop'贝克巴斯(BECBAS)E50 厨房家用食物垃圾处理器 厨余垃圾粉碎机 无线开关免打孔',

        picture:

            'https://image3.suning.cn/uimg/b2c/newcatentries/0000000000-000000000616963357_2_800x800.jpg',

        product:

            '贝克巴斯(BECBAS)E50 厨房家用食物垃圾处理器 厨余垃圾粉碎机 无线开关免打孔',

        price38,

        oldprice67,

        putaway1,

        detailnull,

        categoryname'大家电',

        categoryId9,

    },

    {

        id44,

        shop'科龙(Kelon) 正1.5匹 定速 冷暖 空调挂机 ',

        picture:

            'https://image4.suning.cn/uimg/b2c/newcatentries/0000000000-000000000178605073_1_800x800.jpg',

        product'科龙(Kelon) 正1.5匹 定速 冷暖 空调挂机 ',

        price156.9,

        oldprice190,

        putaway1,

        detail'这个商品不错',

        categoryname'排骨',

        categoryId7,

    },

    {

        id42,

        shop'华帝浴室花洒 增压花洒 主体全铜花洒 沐浴莲蓬头【自营】H-CS0014-B1D.W',

        picture:

            'https://image2.suning.cn/uimg/b2c/newcatentries/0000000000-000000000604135232_1_800x800.jpg',

        product:

            '华帝浴室花洒 增压花洒 主体全铜花洒 沐浴莲蓬头【自营】H-CS0014-B1D.W',

        price18.87,

        oldprice90,

        putaway1,

        detailnull,

        categoryname'排骨',

        categoryId7,

    },

    {

        id41,

        shop'长虹(CHANGHONG) 大1匹 冷暖定频 快速制冷热 空调挂机 KFR-26GW/DHID(W1-J)+2',

        picture:

            'https://image2.suning.cn/uimg/b2c/newcatentries/0000000000-000000000126066901_1_800x800.jpg',

        product:

            '长虹(CHANGHONG) 大1匹 冷暖定频 快速制冷热 空调挂机 KFR-26GW/DHID(W1-J)+2',

        price18.48,

        oldprice35,

        putaway1,

        detailnull,

        categoryname'火锅',

        categoryId8,

    },

    {

        id40,

        shop'小米智米(SMARTMI)智能马桶盖 家用全自动冲洗即热式加热外置过滤器杀菌洁便器',

        picture:

            'https://image4.suning.cn/uimg/b2c/newcatentries/0000000000-000000000673521926_1_800x800.jpg',

        product:

            '小米智米(SMARTMI)智能马桶盖 家用全自动冲洗即热式加热外置过滤器杀菌洁便器',

        price34.98,

        oldprice47,

        putaway1,

        detailnull,

        categoryname'火锅',

        categoryId8,

    },

]

 

/**

 * 轮播数据

 */

export const bannerListData = [

    {

        id1,

        url'https://img.alicdn.com/imgextra/i3/2053469401/O1CN01g8ituT2JJiCsKjmDz_!!2053469401.png',

        content'商家1',

        number1,

    },

    {

        id11,

        url'https://img2.baidu.com/it/u=717979571,3244631707&fm=253&fmt=auto&app=138&f=JPEG?w=1280&h=400',

        content'舒适度满分',

        number1,

    },

    {

        id12,

        url'https://img1.baidu.com/it/u=1733684228,4177721799&fm=253&fmt=auto&app=138&f=JPEG?w=1200&h=500',

        content'曲线在召唤',

        number1,

    },

]

scr同级创建创建mock文件夹→index.ts(模块集成暴露)


import { bannerList, goodsList } from './goods'

import { mockRegister, mockLogin } from './account'

 

const goods = [bannerList, goodsList]  // 商品模块接口

const account = [mockLogin, mockRegister] // 个人中心接口

 

export default [...goods,...account]

4. utils文件夹→requestMock.ts(封装axios,不封装直接使用axios也行)

import axios from 'axios'
import { Toastfrom 'vant'

export let baseURL = 'http://10.7.163.159:5173'  

/**

 * 创建axios实例

 *   封装baseURL

 */

const axiosInstance = axios.create({

    baseURL, // 服务根地址

    timeout3000, // 超时时间

})

 

/**

 * 请求拦截器

 */

axiosInstance.interceptors.request.use(

    config => {

        const token = localStorage.getItem('TOKEN')

        if (token) {

            config.headers['Authorization'] = token

        }

        return config

    },

    error => {

        return Promise.reject(error)

    }

)

/**

 * 响应拦截器

 */

axiosInstance.interceptors.response.use(

    response => {

        return response.data

    },

    error => {

        const { response } = error

        if (response) {

            const status = response.status

            switch (status) {

                case 404:

                    Toast('资源不存在 404')

                    break

                case 401:

                    Toast('Unauthorized 身份验证凭证缺失!')

                    break

                case 403:

                    Toast('403 Forbidden - 拒绝访问!')

                    break

                case 500:

                    Toast('服务器出错')

                    break

                default:

                    Toast('出现异想不到的错误!')

                    break

            }

        } else {

            // 说明服务器连结果都没有返回,可能的原因有两种:

            /**

             * 1. 服务器崩掉了

             * 2. 前端客户端断网状态

             */

            if (!window.navigator.onLine) {

                // 判断为断网,可以跳转到断网页面

                Toast('网络不可用,请检查您的网络连接!')

                return

            } else {

                Toast('连接服务端出错!' + error?.message)

                return Promise.reject(error)

            }

        }

        return Promise.reject(error)

    }

)

export default axiosInstance

5. api文件夹→index.ts文件中定义接口

import axiosInstance from '@/utils/request'
import axiosMockInstance from '@/utils/requestMock'
import type{IResponse} from '@/types/types'


/**

 * 产品列表

 *   shopKey: 店铺名称

 *   productKey: 产品名称

 * @returns 

 */

export const RequestShopList = (pageNo:number,pageSize:number):Promise<IResponse>=>{

    return axiosInstance({

        method:'get',

        url:'/api/shop',

        params:{

            pageSize,

            pageNo,

        }

    })

}

/**

 * 轮播

 */

export const RequestBanner = ():Promise<IResponse>=>{

    return axiosInstance({

        method:'get',

        url:'/api/banner'

    })

}

/**

 * 登录

 */

export const RequestLogin = (username:string,password:string):Promise<IResponse>=>{

    return axiosMockInstance({

        method:'post',

        // url:'/api/member/login',

        url:'/mock/account/login',

        data:{

            username,

            password

        }

    })

}

/**

 * 注册

 */

export const RequestRegister = (username:string,password:string,headerimg:string):Promise<IResponse>=>{

    return axiosMockInstance({

        method:'post',

        url:'/mock/account/register',

        data:{

            username,

            password,

            headerimg

        }

    })

}

.vue文件中引入使用即可

在线PDF拆分工具核心JS实现

这篇只讲本项目里“PDF拆分”工具的功能层 JavaScript 实现。主流程可以概括为:

选择 PDF -> 读取页数 -> 生成拆分页组 -> 复制指定页面 -> 生成多个 PDF -> 单文件下载或 ZIP 打包下载

工具基于 Vue 组织交互状态,核心 PDF 操作使用 pdf-lib,多文件结果打包使用 JSZip,页面预览和书签读取由 pdfjs-dist 辅助完成。

在线工具网址:see-tool.com/pdf-split
工具截图:
工具截图.png

1. 文件进入流程前先做 PDF 判断

文件选择和拖拽上传共用同一套入口。真正加载前,先判断文件类型:

export function isPdfSplitFile(file) {
  if (!file) {
    return false;
  }

  var fileType = String(file.type || "").toLowerCase();
  var fileName = String(file.name || "");
  return fileType === "application/pdf" || /\.pdf$/i.test(fileName);
}

这里同时判断 MIME 和文件后缀,是因为部分浏览器环境下 file.type 可能为空,只依赖 MIME 会误拦正常 PDF。

加载文件时,会把同一份原始字节切成两份用途:

var rawBytes = await file.arrayBuffer();
var splitBytes = rawBytes.slice(0);
var previewBytes = rawBytes.slice(0);
var sourceDoc = await PDFDocument.load(splitBytes);

splitBytes 用于后续拆分,previewBytes 用于预览和书签读取。这样拆分主链路和辅助信息链路互不影响。

2. 页码输入解析成统一的拆分页组

拆分逻辑不是直接处理输入框字符串,而是先转成统一结构:

{
  label: "1-3",
  indices: [0, 1, 2]
}

label 用于文件命名,indicespdf-lib 需要的零基页码数组。

页码范围解析支持逗号分隔,也支持倒序区间:

function buildPageIndices(start, end) {
  var indices = [];
  var page;

  if (start <= end) {
    for (page = start; page <= end; page += 1) {
      indices.push(page - 1);
    }
    return indices;
  }

  for (page = start; page >= end; page -= 1) {
    indices.push(page - 1);
  }

  return indices;
}

所以用户输入 1-3,5,8-6 时,会生成三个输出段:第 1 到 3 页、第 5 页、第 8 到 6 页。

3. 多种拆分模式最终都归一到 groups

工具支持按页码范围、每 N 页、每页单独、奇偶页、可视化选择、书签、平均拆成 N 份。虽然入口不同,但最终都会变成 groups

buildSplitGroups: function () {
  if (this.splitMode === "ranges") {
    return parsePdfSplitRangeGroups(this.rangeInput, this.totalPages);
  }

  if (this.splitMode === "everyN") {
    return buildPdfSplitCountGroups(
      this.totalPages,
      parsePdfSplitPositiveInt(this.everyNInput),
    );
  }

  if (this.splitMode === "everyPage") {
    return buildPdfSplitEveryPageGroups(this.totalPages);
  }

  if (this.splitMode === "evenOdd") {
    return buildPdfSplitEvenOddGroups(this.totalPages, this.evenOddMode);
  }

  if (this.splitMode === "visual") {
    return buildPdfSplitVisualGroups(this.selectedPages);
  }

  if (this.splitMode === "bookmarks") {
    return buildPdfSplitBookmarkGroups(this.bookmarkItems, this.totalPages);
  }

  if (this.splitMode === "nTimes") {
    return buildPdfSplitNPartsGroups(
      this.totalPages,
      parsePdfSplitPositiveInt(this.nTimesInput),
    );
  }

  return [];
}

这个设计的好处是,真正拆分 PDF 时不关心用户选择了哪种模式,只消费统一的页码分组。

4. 可视化选择会自动合并连续页

可视化模式下,用户点选的是离散页码。工具会先排序、去重,再把连续页合并成一个输出段:

export function buildPdfSplitVisualGroups(selectedPages) {
  var uniquePages = Array.isArray(selectedPages)
    ? selectedPages
        .map(function (page) {
          return Number(page);
        })
        .filter(function (page) {
          return Number.isInteger(page) && page > 0;
        })
        .sort(function (left, right) {
          return left - right;
        })
        .filter(function (page, index, source) {
          return index === 0 || page !== source[index - 1];
        })
    : [];

  if (!uniquePages.length) {
    throw createPdfSplitInputError("emptySelection");
  }

  var groups = [];
  var start = uniquePages[0];
  var end = uniquePages[0];

  for (var i = 1; i < uniquePages.length; i += 1) {
    if (uniquePages[i] === end + 1) {
      end = uniquePages[i];
      continue;
    }

    pushMergedSelectionGroup(groups, start, end);
    start = uniquePages[i];
    end = uniquePages[i];
  }

  pushMergedSelectionGroup(groups, start, end);
  return groups;
}

比如选择 1、2、3、7、9、10,结果会拆成 1-379-10 三个文件。

5. 书签拆分按顶层书签生成区间

书签模式先读取 PDF 的 outline,再把书签所在页转换成拆分区间。核心逻辑是:当前书签页作为开始页,下一个书签前一页作为结束页。

export function buildPdfSplitBookmarkGroups(bookmarks, totalPages) {
  var normalizedBookmarks = Array.isArray(bookmarks)
    ? bookmarks
        .filter(function (item) {
          return (
            item &&
            Number.isInteger(Number(item.pageNumber)) &&
            Number(item.pageNumber) >= 1 &&
            Number(item.pageNumber) <= totalPages
          );
        })
        .map(function (item) {
          return {
            title: String(item.title || "").trim() || "bookmark",
            pageNumber: Number(item.pageNumber),
          };
        })
        .sort(function (left, right) {
          return left.pageNumber - right.pageNumber;
        })
    : [];

  var groups = [];

  if (normalizedBookmarks[0].pageNumber > 1) {
    groups.push({
      label: "preface",
      indices: buildPageIndices(1, normalizedBookmarks[0].pageNumber - 1),
      title: "preface",
    });
  }

  for (var index = 0; index < normalizedBookmarks.length; index += 1) {
    var current = normalizedBookmarks[index];
    var next = normalizedBookmarks[index + 1];
    var start = current.pageNumber;
    var end = next ? next.pageNumber - 1 : totalPages;

    groups.push({
      label: current.title,
      indices: buildPageIndices(start, end),
      title: current.title,
    });
  }

  return groups;
}

如果第一个书签不在第一页,前面的内容会单独生成一个 preface 分段。

6. 真正拆分 PDF 的核心是 copyPages

拆分主函数先构建 groups,然后每个分组创建一个新的 PDF:

for (index = 0; index < groups.length; index += 1) {
  var group = groups[index];
  var outputDoc = await PDFDocument.create();
  var copiedPages = await outputDoc.copyPages(
    this.sourceDoc,
    group.indices,
  );

  copiedPages.forEach(function (page) {
    outputDoc.addPage(page);
  });

  var outputBytes = await outputDoc.save();
  var outputBlob = new Blob([outputBytes], {
    type: "application/pdf",
  });

  nextOutputs.push({
    name: this.buildOutputName(group, index, groups.length),
    blob: outputBlob,
    size: outputBlob.size,
  });
}

这里不是修改原 PDF,也不是切割二进制文件,而是把源文档里的指定页面复制到一个新文档。group.indices 决定当前输出文件包含哪些页。

7. 输出文件名根据拆分模式生成

文件名会先清理原 PDF 名称,再结合模式和页码标签生成:

export function buildPdfSplitOutputName(options) {
  var config = options || {};
  var baseName = safePdfSplitBaseName(config.baseName);
  var index = Number(config.index) || 0;
  var total = Number(config.total) || 0;
  var label = String(config.label || "");
  var mode = String(config.mode || "ranges");
  var sequence = String(index + 1).padStart(3, "0");
  var safeLabel = sanitizePdfSplitFileLabel(label) || sequence;

  if (mode === "everyPage") {
    return baseName + "_page_" + safeLabel + ".pdf";
  }

  if (mode === "bookmarks") {
    return baseName + "_" + sequence + "_" + safeLabel + ".pdf";
  }

  if (total === 1) {
    return baseName + "_split.pdf";
  }

  return baseName + "_split_" + sequence + "_p" + safeLabel + ".pdf";
}

这样拆出多个文件时,用户能从文件名看出顺序和页码范围。

8. 单结果直接下载,多结果打包 ZIP

导出时先判断结果数量。只有一个 PDF 时直接下载;多个 PDF 时放进 ZIP:

downloadResult: async function () {
  if (!this.outputs.length) {
    return;
  }

  if (this.outputs.length === 1) {
    this.downloadOutput(this.outputs[0]);
    return;
  }

  var zip = new JSZip();
  this.outputs.forEach(function (item) {
    zip.file(item.name, item.blob);
  });

  var zipBlob = await zip.generateAsync({
    type: "blob",
    compression: "DEFLATE",
    compressionOptions: {
      level: 6,
    },
  });

  this.downloadBlob(zipBlob, "split_result.zip");
}

浏览器下载统一通过 Blob 和临时 a 标签完成:

downloadBlob: function (blob, filename) {
  var url = URL.createObjectURL(blob);
  var link = document.createElement("a");

  link.href = url;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(url);
}

整个 PDF 拆分功能的核心,就是把不同输入方式都转换成稳定的页码分组,再用 pdf-lib 复制页面生成新文档,最后根据结果数量决定直接下载还是打包下载。

解决不同项目需要不同 Node.js 版本的问题

告别“这是在我电脑上能跑”的魔咒:Node.js 多版本管理终极指南

你是否遇到过这样的场景:接手一个老项目,运行时疯狂报错;切回自己的新项目,又提示语法不支持。  根源往往只有一个——Node.js 版本不匹配。

本文将彻底解决这个困扰无数开发者的问题,教你一套优雅的 Node.js 多版本管理方案,让你在不同项目间自由切换,再无环境烦恼。


一、症状:你的Node.js版本管理出问题了

典型“病状”自查:

  • 启动项目时,控制台输出 SyntaxError: Unexpected token '??='(常见于 Node.js 版本过低,不识别新语法)
  • 运行npm install后,依赖死活装不上,或者启动就报错
  • 团队中有人跑得好好的,你拉下来却各种异常
  • 你电脑里明明装了新版Node,老项目却要求你必须降级

如果你中了一条以上,恭喜你,需要开始管理 Node.js 版本了。


二、根本原因:Node.js 版本更新太快,生态碎片化

Node.js 版本 发布时间 主要特性
v12 2019 相对稳定,但较老
v14 2020 LTS(长期支持版,很多老项目仍用)
v16 2021 支持 ??=&&= 等逻辑赋值运算符
v18 2022 支持原生 Fetch、Node.js 测试运行器
v20 2023 稳定版,性能提升
v22+ 2024+ 最新特性,需主动升级

核心矛盾:老项目不敢轻易升(怕 breaking changes),新项目又享受不到新特性。❌ 全局只有一个 Node 版本的模式,必然死路一条。

image.png


三、解决方案核心:nvm(Node Version Manager)

nvm 是什么?
一个让你在电脑上同时安装、共存多个 Node.js 版本,并能在终端里一键切换的工具。

🪟 Windows 用户指南:nvm-windows

1️⃣ 安装前的准备工作(非常重要!)

安装 nvm-windows 之前,务必彻底卸载电脑上原有的 Node.js,避免冲突:

  • “控制面板” -> “程序和功能” -> 卸载 Node.js

  • 手动删除以下残留文件夹(如存在):

    text

    C:\Program Files\nodejs
    C:\Program Files (x86)\nodejs
    C:\Users<你的用户名>\AppData\Roaming\npm
    C:\Users<你的用户名>\AppData\Roaming\npm-cache
    
  • 检查系统的 PATH 环境变量,删除所有与 Node.js 或 npm 相关的路径

2️⃣ 安装 nvm-windows
  1. 访问 nvm-windows 发布页,下载最新版 nvm-setup.zip
  2. 解压后,以管理员身份运行 nvm-setup.exe
  3. 按向导安装,路径建议保持默认(避免权限问题)。
  4. 安装完成后,重启命令行工具(CMD 或 PowerShell)。
3️⃣ 下载加速(国内用户强烈推荐)

在 nvm 安装目录(默认 C:\Users<你的用户名>\AppData\Roaming\nvm)下,找到 settings.txt,末尾添加:

text

node_mirror: https://npmmirror.com/mirrors/node/
npm_mirror: https://npmmirror.com/mirrors/npm/

这样可以大幅提升国内下载 Node.js 的速度。

🍎 macOS / Linux 用户指南:标准版 nvm

在终端中执行:

bash

# 使用 curl
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash

# 或使用 wget
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash

安装脚本会自动将 nvm 加入到你的 shell 配置文件(~/.bashrc~/.zshrc 等)。安装完成后,重启终端或运行 source ~/.zshrc(根据你的 shell 选择)使其生效。


四、一图看懂 nvm 核心操作

我要做什么 命令示例
查看能装哪些 Node 版本 Windows: nvm list available Mac/Linux: nvm ls-remote
安装某个具体版本 nvm install 16.20.0
安装最新的 LTS 版本 nvm install --lts
看我电脑里已有哪些版本 nvm list
在当前终端切换到某个版本 nvm use 16.20.0
设置默认(新打开终端)版本 nvm alias default 16.20.0
删除某个版本 nvm uninstall 16.20.0
查看当前使用版本 node -v

⚠️ Windows 用户特别注意:执行 nvm use 切换版本时,建议以管理员身份打开命令行,否则可能因权限不足而切换失败。


五、终极奥义:自动化项目版本切换(.nvmrc)

再也不用手动记住每个项目用的 Node 版本。

操作步骤

  1. 在项目根目录下,创建一个名叫  .nvmrc 的文件(注意开头有个点)。

  2. 文件内容只需一行,比如:16.20.0(或者 lts/gallium,等别名)。

  3. 当你要进入该项目工作时,在项目根目录执行:

    bash

    nvm use
    

    nvm 会自动读取 .nvmrc 中指定的版本并切换过去。

更高级:自动切换(可选)

如果你希望每次 cd 进项目目录时自动切换,可以借助 avn 或 zsh-nvm 插件。但个人建议:手动执行 nvm use 已经足够简洁,且避免了误切换。


Vue前端SEO优化全攻略(实操落地版,新手也能上手)

Vue作为主流前端框架,其默认的客户端渲染(CSR)模式存在天然SEO短板——SPA页面初始加载仅返回空骨架HTML,核心内容通过JavaScript动态渲染,搜索引擎爬虫可能无法等待JS执行完毕,导致页面内容无法被正常抓取、索引,最终影响网站曝光和排名。

Vue前端SEO优化的核心逻辑的是:让搜索引擎爬虫能轻松抓取页面核心内容、识别页面层级、明确页面价值,本质是解决“爬虫可见性”和“内容可识别性”两大问题。以下方案从基础到进阶,覆盖所有高频优化场景,附具体代码和避坑细节,Vue2/Vue3通用,可直接复制落地。

一、核心优化:解决SPA渲染短板(爬虫抓取核心)

Vue SEO的最大痛点的是“动态内容无法被爬虫抓取”,核心解决方案有3种,根据项目规模和需求选择,优先推荐“预渲染”(低成本、易落地),动态内容多的场景选择“SSR”,快速落地可选择“静态站点生成”。

1. 预渲染(Prerendering):低成本首选,适配静态内容场景

核心逻辑:在项目构建阶段,提前渲染指定路由的静态HTML文件(包含完整内容),部署后用户和爬虫访问时,直接返回渲染好的静态页面,无需等待客户端JS执行,完美解决SPA初始内容为空的问题。

适配场景:内容相对固定的页面(官网、博客详情、产品介绍页),无需服务器额外部署,静态托管即可,开发成本最低。

实操步骤(Vue3+Vite适配):

  1. 安装预渲染插件:pnpm add -D @prerenderer/rollup-plugin(Vite项目);Vue2+Webpack项目可使用prerender-spa-plugin
  2. 配置vite.config.js,指定需要预渲染的路由: import { defineConfig } from 'vite' `` import vue from '@vitejs/plugin-vue' `` import prerender from '@prerenderer/rollup-plugin' ```` export default defineConfig({ `` plugins: [ `` vue(), `` // 预渲染配置 `` prerender({ `` routes: ['/', '/about', '/product', '/contact'], // 需要预渲染的路由(必填) `` renderer: '@prerenderer/renderer-puppeteer' // 渲染器,无需额外配置 `` }) `` ] ``})
  3. 执行npm run build,构建后dist目录会生成每个路由对应的静态HTML文件(如/about/index.html),直接部署即可;
  4. 避坑点:预渲染仅适用于内容固定的页面,动态内容(如实时数据、用户中心)无法预渲染,需结合其他方案;路由较多时,会增加构建时间。

2. 服务端渲染(SSR):动态内容首选,适配高需求场景

核心逻辑:用户/爬虫发起请求时,服务器先执行Vue代码,渲染出完整的HTML(包含动态内容),再将HTML返回给客户端,爬虫可直接抓取完整内容,同时能提升首屏加载速度,是动态内容(电商商品页、资讯列表)的最优解。

适配场景:动态内容多、对SEO和首屏速度要求高的项目(电商、资讯平台),需额外部署Node.js服务器,开发和运维成本较高。

实操方案(两种选择,优先推荐Nuxt.js):

  • 方案1:使用Nuxt.js(Vue官方推荐,简化SSR配置)

    • 创建Nuxt项目(Vue3):npx nuxi init my-nuxt-seo
    • Nuxt自动实现SSR,页面组件中可通过asyncDatafetch获取服务端数据,确保渲染的HTML包含动态内容: <script setup> `` // 服务端获取数据,渲染到HTML中,爬虫可直接抓取 `` const { data } = await useAsyncData('productList', () => { `` return fetch('/api/product').then(res => res.json()) `` }) ``</script>
    • 部署:需部署到支持Node.js的服务器(如阿里云ECS、Vercel),Nuxt提供一键部署方案,降低运维成本。
  • 方案2:自定义SSR(Vue2/Vue3通用,灵活度高)

    • 基于Express+vue-server-renderer实现,核心是创建服务端渲染入口,将Vue组件渲染为HTML字符串,返回给客户端;
    • 注意:需区分客户端和服务端环境,避免在服务端使用window、document等浏览器API,否则会报错。

补充:SSR的核心优势是支持动态内容抓取,但需注意服务器负载,可通过CDN缓存优化,减少服务器压力。

3. 静态站点生成(SSG):折中方案,兼顾成本和动态性

核心逻辑:在构建阶段生成所有页面的静态HTML(类似预渲染),但支持动态数据注入,构建后可静态托管,同时能通过增量构建更新内容,适配内容更新频率较低的动态场景(如每周更新的资讯、商品页)。

实操方案(Vue3+ViteSSG):

  1. 安装插件:pnpm add -D vite-ssg
  2. 改造入口文件main.ts(替换createApp,交给ViteSSG接管): import { ViteSSG } from 'vite-ssg' `` import App from './App.vue' `` import { routes } from './router' // 导出路由数组,而非router实例 ```` // 核心改造:ViteSSG生成静态站点 `` export const createApp = ViteSSG( `` App, `` { routes, base: import.meta.env.BASE_URL }, `` ({ app, router }) => { `` // 注册插件(如Pinia、VueMeta) `` } ``)
  3. 路由配置改造:需导出routes数组,且必须使用History模式,避免Hash模式破坏静态页面结构;
  4. 优势:无需部署Node.js服务器,静态托管即可,支持动态数据注入,构建后页面加载速度快,爬虫抓取友好。

二、基础优化:元信息(Meta)配置(爬虫识别核心)

搜索引擎爬虫抓取页面时,首先读取页面的元信息(Title、Description、Keywords等),用于判断页面主题和价值,是SEO优化的基础,必须每个页面配置独立的元信息,避免全局统一配置导致的权重分散。

1. 核心插件:vue-meta(Vue2/Vue3通用)

用于在组件级别管理元信息,支持动态设置Title、Meta标签、OG标签(用于社交媒体分享),无需手动操作DOM,适配SPA、SSR、SSG所有场景。

实操步骤:

  1. 安装插件:npm install vue-meta --save
  2. 全局注册(main.ts): import { createApp } from 'vue' `` import App from './App.vue' `` import VueMeta from 'vue-meta' ```` const app = createApp(App) `` app.use(VueMeta, { `` refreshOnceOnNavigation: true // 路由切换时刷新元信息 `` }) ``app.mount('#app')
  3. 组件中配置(每个页面独立配置): <script setup> `` // Vue3组合式API配置 `` useMeta({ `` title: 'Vue SEO优化指南 | 新手也能落地的实操方案', // 页面标题(核心,包含关键词) `` htmlAttrs: { lang: 'zh-CN' }, // 页面语言,帮助爬虫识别 `` meta: [ `` { name: 'description', content: '本文详细讲解Vue前端SEO优化方法,包含预渲染、SSR、元信息配置等实操技巧,适合新手学习,可直接复制落地。' }, // 页面描述(吸引点击,包含核心关键词) `` { name: 'keywords', content: 'Vue SEO, Vue前端SEO, Vue预渲染, Vue SSR' }, // 核心关键词(3-5个为宜,避免堆砌) `` // OG标签(优化社交媒体分享,提升曝光) `` { property: 'og:title', content: 'Vue SEO优化指南' }, `` { property: 'og:description', content: '新手也能落地的Vue前端SEO实操方案' }, `` { property: 'og:type', content: 'article' } `` ] `` }) ``</script>

2. 路由级元信息配置(统一管理,避免遗漏)

通过Vue Router的meta配置,统一管理所有页面的元信息,结合全局导航守卫,实现路由切换时自动更新元信息,适合页面较多的项目。

// router/index.ts(Vue3)
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    component: () => import('../views/Home.vue'),
    meta: {
      title: '首页 | Vue SEO优化实战',
      metaTags: [
        { name: 'description', content: '首页:专注Vue前端SEO优化,分享可落地的实操技巧' },
        { name: 'keywords', content: 'Vue SEO, 前端SEO, Vue优化' }
      ]
    }
  },
  {
    path: '/product/:id',
    component: () => import('../views/Product.vue'),
    meta: {
      title: '产品详情 | Vue SEO优化实战',
      metaTags: [
        { name: 'description', content: '产品详情页,展示Vue SEO相关工具和方案' },
        { name: 'keywords', content: 'Vue产品, SEO工具, Vue优化方案' }
      ]
    }
  }
]

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

// 全局导航守卫:路由切换时更新元信息
router.beforeEach((to, from, next) => {
  // 更新页面标题
  document.title = to.meta.title || 'Vue SEO优化指南'
  
  // 移除已存在的meta标签,避免重复
  const existingTags = document.querySelectorAll('meta[name^="vue-meta-"]')
  existingTags.forEach(tag => tag.parentNode.removeChild(tag))
  
  // 添加新的meta标签
  if (to.meta.metaTags) {
    to.meta.metaTags.forEach(tag => {
      const metaTag = document.createElement('meta')
      metaTag.setAttribute('name', tag.name)
      metaTag.setAttribute('content', tag.content)
      metaTag.setAttribute('vue-meta', '1')
      document.head.appendChild(metaTag)
    })
  }
  
  next()
})

export default router

3. 避坑点

  • Title:每个页面独立,包含1-2个核心关键词,长度控制在30字以内,避免堆砌关键词;
  • Description:简洁明了,包含核心关键词,长度控制在120字以内,吸引用户点击,避免和其他页面重复;
  • Keywords:3-5个为宜,贴合页面内容,避免堆砌(如“Vue,SEO,VueSEO,前端优化,SEO优化”);
  • OG标签:必须配置,优化微信、微博等社交媒体分享时的预览效果,提升页面曝光率。

三、内容优化:让爬虫“读懂”页面内容

即使解决了渲染问题,若页面内容杂乱、结构不清晰,爬虫仍无法识别核心价值,需优化内容结构和标签使用,提升页面权重。

1. 语义化标签使用(核心)

Vue模板中优先使用语义化标签,替代div嵌套,帮助爬虫识别页面层级和内容类型,提升页面可读性。

<!-- 推荐:语义化标签,清晰区分页面结构 --&gt;
&lt;header&gt;
  &lt;h1&gt;Vue SEO优化指南&lt;/h1&gt; <!-- 每个页面只有1个h1,作为页面核心标题 -->
  <nav><!-- 导航栏 -->
    <a href="/" rel="canonical">首页</a>
    <a href="/about">关于我们</a>
  </nav>
</header&gt;
&lt;main&gt;<!-- 页面核心内容 -->
  <section><!-- 内容区块 -->
    <h2>一、核心优化方案</h2><!-- h2-h6层级递减,不跳级 -->
    <p>Vue SEO的核心是解决爬虫抓取问题,主要有3种方案...</p>
  </section&gt;
&lt;/main&gt;
&lt;footer&gt;<!-- 页脚 -->
  <p>© 2026 Vue SEO优化指南 版权所有</p>
</footer>

关键要点:

  • 每个页面只有1个h1标签,作为页面核心标题,包含核心关键词;
  • h2-h6标签层级递减,不跳级(如h1之后是h2,h2之后是h3),清晰区分内容层级;
  • 使用header、main、nav、section、footer等语义化标签,替代div,帮助爬虫识别页面结构。

2. 动态内容优化(爬虫可识别)

对于SPA中的动态内容(如列表、详情),除了使用SSR/SSG/预渲染,还需注意:

  • 避免使用v-if隐藏核心内容:爬虫可能无法识别v-if控制的内容,若必须隐藏,可使用v-show(通过CSS隐藏,内容仍在HTML中);
  • 图片、视频添加alt属性:图片需添加alt属性(描述图片内容,包含关键词),视频添加title属性,帮助爬虫识别多媒体内容; <!-- 正确示例:图片添加alt属性 --> ``<img src="/vue-seo.jpg" alt="Vue前端SEO优化实操步骤" />
  • 结构化数据标记(Schema.org):给核心内容(如文章、产品、资讯)添加结构化数据,帮助搜索引擎理解内容类型,提升搜索排名(如电商商品可标记价格、评分,文章可标记作者、发布时间): <script setup> `` useMeta({ `` script: [ `` { `` type: 'application/ld+json', `` json: { `` "@context": "https://schema.org", `` "@type": "Article", `` "name": "Vue前端SEO优化全攻略", `` "description": "新手也能落地的Vue SEO实操方案", `` "author": { "@type": "Person", "name": "前端开发者" }, `` "datePublished": "2026-04-23" `` } `` } `` ] `` }) ``</script>

3. 内部链接优化

  • 页面之间添加合理的内部链接(如首页链接到产品页、文章页),帮助爬虫抓取更多页面,提升网站整体权重;
  • 避免使用空链接、死链接,链接文本需贴合目标页面内容(如“查看Vue预渲染教程”,而非“点击这里”);
  • 使用rel="canonical"标签,避免页面重复(如同一内容有多个URL,指定规范URL),防止权重分散: <a href="/product" rel="canonical">产品列表</a>

四、性能优化:提升页面加载速度(辅助SEO)

搜索引擎优先收录加载速度快的页面,Vue项目的性能优化不仅能提升用户体验,还能间接提升SEO排名,核心优化点如下:

1. 资源优化

  • 图片优化:压缩图片(使用tinypng等工具),使用WebP格式,懒加载(避免首屏加载过多图片),Vue中可使用vue-lazyload插件: // 安装:npm install vue-lazyload --save `` // 全局注册 `` import VueLazyload from 'vue-lazyload' `` app.use(VueLazyload, { `` loading: '/loading.png', // 加载中占位图 `` error: '/error.png' // 加载失败占位图 `` }) `` // 页面使用 ``<img v-lazy="imgSrc" alt="Vue SEO优化" />
  • JS/CSS优化:开启Gzip压缩(需服务器配置),拆分代码(路由懒加载),减少首屏加载体积: // 路由懒加载(Vue Router) `` const routes = [ `` { `` path: '/about', `` component: () => import('../views/About.vue') // 懒加载,按需加载组件 `` } ``]
  • 静态资源CDN托管:将图片、JS、CSS等静态资源部署到CDN(如阿里云CDN),提升资源加载速度,减轻服务器压力。

2. 首屏加载优化

  • 减少首屏JS体积:移除无用代码,按需引入第三方插件(如Element Plus可按需引入组件);
  • 预加载核心资源:使用<link rel="preload">预加载首屏必需的资源(如核心JS、CSS);
  • 优化webpack/vite配置:压缩代码、移除注释,减少构建后文件体积: // vue.config.js(Vue2+Webpack) `` module.exports = { `` configureWebpack: config => { `` config.plugin('html').tap(args => { `` args[0].minify = { `` removeComments: true, `` collapseWhitespace: true, // 压缩HTML `` removeAttributeQuotes: true `` } `` return args `` }) `` } ``}

五、其他关键优化(避坑必看)

1. 路由优化(History模式)

Vue Router默认使用Hash模式(URL带#),#后面的内容无法被爬虫识别,需切换为History模式,并配置服务器,避免404错误。

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
  history: createWebHistory(), // 切换为History模式
  routes
})

服务器配置(以Nginx为例):

server {
  listen 80;
  server_name your-domain.com;
  root /usr/share/nginx/html; # 部署目录
  
  # 解决History模式404问题
  location / {
    try_files $uri $uri/ /index.html;
  }
}

2. 避免SEO黑名单操作

  • 禁止隐藏关键词(如文字颜色和背景色一致)、堆砌关键词,会被搜索引擎判定为作弊,降低排名;
  • 禁止使用iframe嵌套核心内容,爬虫可能无法抓取iframe内的内容;
  • 禁止动态生成的内容完全依赖JS(如无SSR/预渲染,仅通过JS渲染核心内容),会导致爬虫无法抓取。

3. 配置robots.txt和sitemap.xml

  • robots.txt:放在网站根目录,指定爬虫可抓取和不可抓取的页面,避免爬虫抓取无用页面(如后台管理页): # robots.txt `` User-agent: * # 所有爬虫 `` Allow: / # 允许抓取所有页面 `` Disallow: /admin/ # 禁止抓取后台页面 ``Disallow: /api/ # 禁止抓取接口页面
  • sitemap.xml:生成网站地图,列出所有需要被抓取的页面,提交给百度、谷歌等搜索引擎,帮助爬虫快速抓取所有页面,提升收录效率。

六、优化效果验证(必做步骤)

优化完成后,需验证优化效果,确保爬虫能正常抓取页面内容,核心验证工具和步骤:

  1. 查看页面源码:右键“查看页面源代码”,确认核心内容、元信息、语义化标签是否存在(非空骨架);
  2. 百度搜索资源平台:提交网站、sitemap.xml,使用“URL提交”功能,验证页面是否能被收录;
  3. Google Search Console:验证页面收录情况,查看爬虫抓取错误,及时调整优化方案;
  4. SEO检测工具:使用爱站、站长工具等,检测页面元信息、关键词密度、加载速度等,优化不足的地方。

七、总结(实操优先级)

Vue前端SEO优化的实操优先级:渲染方式优化(预渲染/SSR/SSG)→ 元信息配置 → 内容语义化 → 性能优化 → 路由/robots配置

新手建议:先从预渲染+元信息配置入手(低成本、易落地),解决核心的爬虫抓取问题;若项目有动态内容,再升级为SSR/SSG;最后优化内容和性能,提升页面权重和排名。

核心原则:SEO优化是长期过程,需持续更新内容、监控抓取情况、调整优化方案,才能逐步提升网站曝光和排名。

从零搭建音视频通话太痛苦?这个 Vue3 CallKit 让你 5 分钟搞定 1v1 + 群聊通话

📌 声明:本篇文章基于 Easemob Chat CallKit Vue3 开源项目由 AI 辅助生成。

如果你正在 Vue3 项目中集成音视频通话功能,却被信令协议、状态管理、多端同步、UI 布局搞得焦头烂额——Easemob Chat CallKit Vue3 可能是你一直在找的答案。基于环信 IM + 声网 RTC,内置完整的呼叫、接听、挂断、群聊网格、媒体控制能力,真正的开箱即用


😫 我们先聊聊:自建音视频通话,到底难在哪?

做过实时音视频的同学应该都有体会,这玩意儿看起来只是"接个 SDK",真动手才发现坑一个接一个:

1. 信令层 = 自己造轮子

IM 消息和 RTC 是完全两套系统。呼叫、接听、拒绝、挂断、超时、占线……每一个动作都要自己定义信令格式、处理发送失败、重连补偿、离线消息过滤。稍不留神,两端状态就对不上——我这边显示"通话中",对方那边已经挂了。

2. 状态管理是灾难

单聊还勉强能搞个 isCalling flag,群聊直接懵圈:谁加入了、谁拒绝了、谁掉线了、谁在响铃中、视频轨道谁发布了……状态一多,Vue 的响应式系统开始疯狂重渲染,内存泄漏和竞态条件接踵而至。

3. UI 实现成本被严重低估

视频通话的 UI 不是简单放个 <video> 标签。单聊要有悬浮窗、最小化、拖拽、画中画;群聊要有九宫格、主视频模式、说话者高亮、成员管理。这些交互写起来没半个月下不来。

4. 单聊和群聊像是两个世界

单聊是二元状态机(呼叫方 ↔ 被叫方),群聊是分布式参与者集合。两者的信令协议、状态模型、UI 布局完全不同,很多团队最后不得不维护两套代码。


🎯 CallKit 解决什么问题?一句话:把上面这些坑全部填平

Easemob Chat CallKit Vue3 是环信官方推出的音视频通话 UI 组件库,基于 Vue 3 + 环信 IM SDK + 声网 RTC SDK,把信令、状态、UI 全部封装好,开发者只需要关心三件事:

  1. 我已经登录了环信 IM(你本来就要做聊天功能对吧?)
  2. 我要呼叫谁(传一个 userId 或 groupId)
  3. 我要监听什么事件(通话结束记个时长、写条消息)

其他的一切——信令收发、RTC 频道管理、视频渲染、邀请弹窗、通话计时、静音/摄像头切换——全部内置


🚀 5 分钟接入:从安装到打通第一通电话

安装依赖

# 安装 CallKit(以及你项目里已有的 IM 和 RTC SDK)
pnpm add easemob-chat-callkit-vue3 easemob-websdk agora-rtc-sdk-ng

Step 1:注册插件

// main.ts
import { createApp } from 'vue'
import EasemobChatCallKit from 'easemob-chat-callkit-vue3'
import 'easemob-chat-callkit-vue3/style.css'
import App from './App.vue'

const app = createApp(App)
app.use(EasemobChatCallKit)
app.mount('#app')

Step 2:在根组件放置 Provider + 通话组件

<template>
  <EasemobChatCallKitProvider :chat-client="chatClient" :init-config="{ logLevel: 2 }">
    <!-- 你的应用内容 -->
    <router-view />

    <!-- 被叫邀请弹窗(自动弹出,无需 v-if) -->
    <InvitationNotification />

    <!-- 单人通话组件(自动显隐) -->
    <EasemobChatSingleCall />

    <!-- 群组通话组件(自动显隐) -->
    <EasemobChatMultiCall :group-id="currentGroupId" />
  </EasemobChatCallKitProvider>
</template>

<script setup lang="ts">
import {
  EasemobChatCallKitProvider,
  InvitationNotification,
  EasemobChatSingleCall,
  EasemobChatMultiCall,
} from 'easemob-chat-callkit-vue3'

// 你已经有的环信 IM 实例
const chatClient = /* 你的 easemob-websdk Connection */
const currentGroupId = /* 当前群组 ID */
</script>

Step 3:发起通话

<script setup lang="ts">
import { useCallKit } from 'easemob-chat-callkit-vue3'

const { call, groupCall, hangup, accept, reject } = useCallKit()

// ── 发起 1v1 视频通话 ──
await call({
  targetId: 'user123',
  type: 'video',
  userInfo: {
    nickname: '张三',
    avatarURL: 'https://example.com/avatar.png'
  }
})

// ── 发起群组视频会议 ──
await groupCall({
  groupId: 'group001',
  members: ['user1', 'user2', 'user3'],
  type: 'video',
  groupName: '产品周会',
})

// ── 挂断 ──
await hangup()
</script>

就这三步。 不需要手动创建 RTC 频道,不需要处理信令消息,不需要写 v-if 控制组件显示隐藏。EasemobChatSingleCall 会根据内部状态自动出现和消失。


🎧 监听通话生命周期,集成到你的业务

通话结束后想记个时长?收到来电想响个铃?用 useCallKitEvents() 一站式订阅:

import { useCallKitEvents, HANGUP_REASON } from 'easemob-chat-callkit-vue3'
import { onUnmounted } from 'vue'

const {
  onCallStarted,
  onCallEnded,
  onIncomingCall,
  onCallRefused,
  getCallRecord,   // ← 自动生成标准化通话记录
} = useCallKitEvents()

// 通话接通
const unbind1 = onCallStarted((e) => {
  console.log('通话开始', e.callId, '频道:', e.channel)
})

// 通话结束 → 自动获取通话记录,插入消息列表
const unbind2 = onCallEnded((e) => {
  const sec = Math.round(e.duration / 1000)
  console.log('通话结束,时长:', sec, '秒,原因:', e.reason)

  // 直接拿到标准化记录,无需自己拼凑字段
  const record = getCallRecord()
  // record = { callId, conversationId, chatType, from, to, status, duration, timestamp, endedBy }
  // 可以直接插入本地消息或发送 custom 消息
})

// 组件卸载时解绑,防止内存泄漏
onUnmounted(() => { unbind1(); unbind2() })

所有事件都携带 conversationIdisLocallocalUserRole 字段——你再也不用自己推断"这是单聊还是群聊""这是本端触发还是对端触发"。


🏗️ 设计思路:为什么 CallKit 能做得这么薄?

很多开发者会担心:"封装得这么彻底,灵活性会不会很差?" 不会。CallKit 的架构设计核心就一句话:"该隔离的隔离,该共享的共享"

四层架构模型

┌─────────────────────────────────────────────┐
│              UI 层(完全隔离)                │
│   SingleCall (1v1 悬浮窗) │ GroupCallShell   │
│   (自动显隐/拖拽/画中画)   │ (九宫格/主视频)   │
└─────────────────────────────────────────────┘
                     ↕
┌─────────────────────────────────────────────┐
│           状态层(领域隔离 + 共享)            │
│  SingleCallStore  │  GroupCallStore          │
│  (二元状态机)      │  (分布式参与者集合)       │
│                   ↕                          │
│         GlobalCallStore(跨域共享)           │
│         userInfoMap / isMinimized            │
└─────────────────────────────────────────────┘
                     ↕
┌─────────────────────────────────────────────┐
│           服务层(无状态,纯原子能力)          │
│   SignalingService    │   RtcChannelService  │
│   (发/收信令)          │   (join/leave/track) │
└─────────────────────────────────────────────┘
                     ↕
┌─────────────────────────────────────────────┐
│           基础设施层(外部 SDK)               │
│        环信 IM SDK  │  声网 RTC SDK          │
└─────────────────────────────────────────────┘

关键设计决策

层级 策略 理由
UI 层 单聊/群聊彻底隔离 单聊是"一对一窗口+拖拽",群聊是"多方网格+主视频",交互模式差异巨大,强行复用只会增加复杂度
状态层 领域隔离 + GlobalCallStore 共享 单聊是二元状态机(IDLE → INVITING → IN_CALL → IDLE),群聊是分布式参与者集合;但 userInfoMap(头像/昵称)、isMinimized 需要跨域共享
服务层 无状态共享 sendInviteMessagejoinChannelcreateAudioTrack 是通用能力,不应绑定任何业务状态
基础设施 单实例共享 IM 连接和 RTC 客户端各一个实例,避免资源浪费

自动显隐:开发者不用写 v-if

单聊组件内置了状态驱动的显隐逻辑:

  • INVITING(呼叫中)→ 显示等待界面
  • ALERTING(被叫响铃)→ 不显示,由 InvitationNotification 接管弹窗
  • IN_CALL(通话中)→ 显示视频流界面
  • IDLE(空闲)→ 自动隐藏

这意味着你把组件往模板里一放,剩下的交给 CallKit。

类型安全的事件系统

不像传统 EventBus 的 any 类型,useCallKitEvents() 提供完全类型化的事件订阅:

onCallEnded((e) => {
  e.reason      // HANGUP_REASON 枚举,有代码提示
  e.duration    // number,毫秒
  e.conversationId // string,单聊=对方ID,群聊=groupId
  e.isLocal     // boolean,true=本端挂断
})

每个事件都返回解绑函数,组件卸载时自动清理,告别内存泄漏。

过期信令自动过滤

多端登录、离线重连时,过期的邀请/取消信令会堆积。CallKit 内置了时间戳过期判断(invite 40s 阈值,cmd 60s 阈值),自动丢弃过期消息,避免"幽灵弹窗"。


📦 更多能力一览

特性 说明
📞 单人通话 1v1 音频/视频,支持呼叫、接听、拒绝、挂断
👥 群组通话 多人音视频,支持邀请成员、视频网格布局
🔔 邀请通知 被叫方自动弹出接听/拒绝弹窗
🎛️ 媒体控制 静音、开关摄像头、切换前后置
🖼️ 视频布局 单聊悬浮窗+最小化;群聊九宫格/主视频模式
🎯 自动显隐 根据通话状态自动显示/隐藏,无需手动 v-if
📊 通话记录 getCallRecord() 自动生成标准化记录
🔧 源码调试 支持 Vite alias 映射到源码,开发时热更新

📝 适用场景

  • 社交 App:1v1 语音/视频通话、多人语音房
  • 在线教育:1v1 答疑、小班课视频互动
  • 远程医疗:医患视频问诊
  • 企业协作:群组视频会议、远程面试
  • 任何已有环信 IM 的项目:聊天功能已经用了环信,直接叠加通话能力

😁效果图片

待接听

image.png

视频通话中

image.png

群组通话中

image.png

被叫待接听

image.png

🔗 相关链接

资源 链接
📘 GitHub 仓库 github.com/Easemob-Com…
📖 完整 API 文档 USAGE.md
🚀 体验地址 线上地址
🏢 环信官网注册 www.easemob.com/
📦 npm 包 easemob-chat-callkit-vue3

💡 写在最后

音视频通话是现代应用的标配,但自建成本极高。Easemob Chat CallKit Vue3 的思路是:把通用能力下沉到组件库,把业务逻辑留给开发者。你只需要关心"什么时候发起通话"和"通话结束后做什么",中间最复杂的信令协商、状态同步、视频渲染,全部封装在组件内部。

如果你正在用 Vue3 开发即时通讯应用,或者正为集成音视频通话头疼,不妨试试这个方案。注册环信账号、创建应用、获取 App Key,几分钟就能跑通第一通电话。

环信官网注册入口 👉 www.easemob.com/

注册后即可创建应用,免费体验完整的 IM + 音视频通话能力。


本文介绍的 Easemob Chat CallKit Vue3 基于 MIT 协议开源,欢迎 Star 和 PR。

[Vue]可重置的响应式状态reactive

本文介绍了一个Vue框架下的可重置的响应式状态创建函数,用于创建出可重置的reactive。

源码

useResettableState.ts

import { reactive } from 'vue';
import { cloneDeep } from 'lodash-es';

/**
 * 创建一个可重置的响应式状态
 * @param initialStateFactory 返回初始状态的函数
 * @returns 包含响应式 state 和 reset 方法的对象
 */
export function useResettableState<T extends Record<string, any>>(initialStateFactory: () => T) {
  // 获取初始状态并深度克隆(用于后续重置)
  const initialState = cloneDeep(initialStateFactory());

  // 创建响应式状态(深度克隆避免引用共享)
  const state = reactive(cloneDeep(initialState)) as T;

  /**
   * 重置状态为初始值
   */
  const reset = (): void => {
    const freshState = cloneDeep(initialState);
    // 清除当前所有属性(处理动态增删字段的场景)
    Object.keys(state).forEach((key) => {
      delete (state as Record<string, any>)[key];
    });
    // 恢复初始结构
    Object.keys(freshState).forEach((key) => {
      (state as Record<string, any>)[key] = freshState[key];
    });
  };

  return {
    state,
    reset,
  };
}

使用示例

import { useResettableState } from '@/tools/composables/useResettableState';

// 一个表单对象
const { state: stateForm, reset: resetStateForm } = useResettableState(() => ({
  name: '',
  type: 'user' as 'user' | 'system'
  isEnabled: true, 
  file: undefined as File | undefined, 
}));

function submitForm() {
  // 模拟提交
  ...
  
  // 提交成功后重置表单
  resetStateForm();
}

Vue 全局监控用户行为,最强方案!

📊 产品定位

WebTracing 是一款基于 JavaScript 开发的前端埋点工具包(SDK),专门为 Web 应用打造全链路监控方案,能够全方位覆盖前端监控场景,助力开发者实现应用的精准监控与优化。

🌟 核心能力

该SDK全面覆盖八大核心监控维度,实现前端场景无死角监控:

  • 行为监控:精准追踪用户各类交互操作,还原用户行为路径
  • 性能监控:深入分析页面加载全过程及运行时性能表现
  • 异常监控:自动捕获 JavaScript 运行过程中的各类错误
  • 请求监控:实时追踪 HTTP 请求的状态、耗时等关键指标
  • 资源监控:细致分析静态资源的加载速度与异常情况
  • 路由监控:适配 SPA 应用,精准追踪路由切换状态
  • 曝光监控:检测页面元素的可见性,统计曝光数据
  • 录屏功能:回放用户操作行为,便于问题回溯与分析

✨ 技术特性

  • 原生兼容:采用纯 JavaScript 开发,支持所有现代主流浏览器
  • 框架适配:针对性提供 Vue2、Vue3 专用版本,开箱即用
  • 轻量高效:采用轻量化设计,gzip 压缩后体积不足 15KB,不占用过多资源
  • 灵活可配:支持 20 余种定制化参数,可根据业务需求灵活调整
  • 数据优化:采用智能缓存与批量上报机制,有效降低网络开销

📦 快速集成

安装方式

# 原生 JavaScript 项目
pnpm install @web-tracing/core

# Vue2 项目
pnpm install @web-tracing/vue2

# Vue3 项目  
pnpm install @web-tracing/vue3

🌐 原生 JS 集成示例

<script src="https://cdn.jsdelivr.net/npm/@web-tracing/core"></script>
<script>
  webtracing.init({
    dsn'https://api.your-domain.com/track',
    appName'web_app',
    tracesSampleRate0.2,  // 生产环境采样率设置
    ignoreErrors: [/ResizeObserver loop/],
    beforeSendDatadata => {
      data.env"production";
      return data
    }
  })
</script>

🖥️ Vue3 集成示例

import WebTracing from '@web-tracing/vue3'

app.use(WebTracing, {
  dsn: '/track',
  performance: true,      // 开启性能监控功能
  error: {                // 精细化配置错误捕获规则
    captureUnhandledRejections: true
  },
  cacheMaxLength: 20,     // 扩大缓存队列容量
})

🔧 关键配置详解

配置项 类型 默认值 说明
tracesSampleRate number 1.0 数据采样率,取值范围为 0.1~1.0
cacheWatingTime number 1000 缓存批量上报的时间间隔(单位:ms)
scopeError boolean false Vue 专属配置,用于开启组件级错误捕获

⚡ 过滤规则配置

{
  ignoreErrors: [
    "CustomIgnoreError", 
    /^SecurityError:/
  ],
  ignoreRequests: [
    /healthcheck/,
    /.(png|css|js)$/
  ]
}

�深度解析核心功能

1. 全链路错误追踪

// 主动捕获异常并上报
webtracing.captureException(error, {
  tags: { module'checkout' },
  extra: { cartId'a1b2c3' }
})

// 监听未处理的Promise异常
window.addEventListener('unhandledrejection', e => {
  webtracing.captureException(e.reason)
})

2. 精细化性能分析

// 标记关键业务流程的开始与结束
webtracing.markStart('payment_processing')
processPayment()
webtracing.markEnd('payment_processing')

// 获取页面LCP(最大内容绘制)指标
const lcpEntry = performance.getEntriesByName('LCP')[0]
console.log(lcpEntry.startTime)

3. 智能曝光追踪

<!-- 采用声明式方式配置曝光监控 -->
<div data-exposure-track="promo_banner" data-exposure-ratio="0.6">
  <!-- 广告或需要监控曝光的内容 -->
</div>

🚀 最佳实践

生产环境推荐配置

{
  dsn'https://log.your-app.com',
  tracesSampleRate0.1,   // 高流量场景建议10%采样率
  cacheMaxLength30,      // 扩大缓存队列,减少上报次数
  cacheWatingTime2000,   // 设置2秒批量上报间隔
  ignoreErrors: [
    /^CanceledError/,
    /ResizeObserver loop/
  ]
}

用户行为追踪策略

// 封装关键转化事件追踪方法
exportconst trackConversion = (eventName, params) => {
  webtracing.track(eventName, {
    ...params,
    sessionId: getSessionId(),
    timestamp: Date.now()
  })
}

// 示例:追踪用户购买行为
trackConversion('purchase', {
orderId'ord_123'amount299.00
})

📈 监控数据示例

性能数据格式

{
  "type": "performance",
  "metrics": {
    "FCP": 1240,
    "LCP": 2850,
    "CLS": 0.08
  },
  "pageUrl": "/products/123"
}

错误数据格式

{
  "type": "error",
  "message": "Cannot read property 'price'",
  "stack": "...",
  "component": "ProductCard.vue",
  "environment": "production"
}

为什么 React 和 Vue 不一样?

依旧能记起当年 React 和 Vue 刚火时,前端之间一直有个争论:使用 React 还是使用 Vue。当年这个议题吵的热火朝天,当时就在想,为什么这两个框架会有这么大的差异?造成这些差异的原因是什么?为什么两个框架走的不同路径,但是给开发者的体验却是相似的?种种问题都在我的脑海中回荡,可惜当年还是一个初入门的小白,虽然有这些问题,但是还是没有自己找到答案。最近横向和纵向各个维度深度对比了这两个框架,答案就呼之欲出了。挺好,似乎回到了入门的起点。


一、为什么两种架构走向了不同的道路

1.1 UI 的本质是什么

要理解 React Fiber 和 Vue 响应式系统为何走向截然不同的架构路径,我们必须回到一个更根本的问题:用户界面的本质是什么。React 团队给出的答案是——UI 是状态的函数(UI = f(state))。这个看似简单的等式蕴含着深刻的架构决策:如果 UI 只是状态的纯粹映射,那么每次状态变化时,整个 UI 都应该被重新计算,框架的职责是通过 diff 算法来最小化实际的 DOM 操作。React 的虚拟 DOM 和 Fiber 架构都是这一观点的工程实现,它们假设状态变化是不可预测的、细粒度的,因此需要一个通用的运行时调度系统来处理任意复杂度的更新。这种设计赋予了 React 极强的灵活性和表达能力,但也带来了不可避免的运行时开销——每一次更新都需要走过"渲染 -> 虚拟 DOM 树构建 -> Diff -> Patch"的完整链路。

Vue 的创始人尤雨溪对这个问题给出了不同的回答。在他看来,UI 的本质是响应式数据与 DOM 之间的绑定关系。当开发者声明了一个模板(Template),其中的每一个插值表达式({{ }})、每一个指令(v-bindv-ifv-for)都是在建立数据到视图的明确映射。Vue 的核心在于:这种映射关系在编译时就可以被静态分析出来。因此,Vue 选择将大部分优化工作前置到编译阶段,通过编译器生成带有优化标记的渲染函数,让运行时的更新工作变得精准而高效。Vue 3 的 Proxy 响应式系统进一步强化了这种理念——当数据变化时,框架精确知道哪些组件、哪些 DOM 节点需要更新,不需要进行全树扫描。两种框架的分歧从这一刻起就已经注定:React 押注运行时调度的通用性和灵活性,Vue 押注编译时优化的精准性和效率。

1.2 两条路径的技术DNA

React 的技术 DNA 可以追溯到底层系统编程的启发。Fiber 架构的设计者 Andrew Clark 曾明确表示,Fiber 是对操作系统线程调度模型的借鉴。在操作系统中,进程调度器需要在多个任务之间分配 CPU 时间片,确保高优先级任务(如用户输入)能够及时响应,同时不让低优先级任务(如后台计算)饿死。React Fiber 将同样的思想引入了 JavaScript 的单线程环境:通过将渲染工作拆分为可中断的"工作单元",并利用浏览器的 requestIdleCallback 机制,React 可以在每一帧的空闲时间内执行一小部分渲染工作,高优先级更新则可以随时 "抢占" 当前工作。这种架构赋予了 React 时间切片并发渲染的能力,使得 React 能够在不阻塞主线程的前提下处理大规模组件树的更新。

Vue 的技术 DNA 则源于 数据绑定依赖追踪 。Vue 2 使用 Object.defineProperty 对数据对象进行递归劫持,在 getter 中收集依赖,在 setter 中触发更新。Vue 3 则将这一机制升级为基于 ES6 Proxy 的响应式系统,配合 Reflect API 实现更完整、更高效的拦截。Vue 的核心设计哲学是 让框架自动追踪数据与视图之间的依赖关系,开发者无需手动声明依赖(不像 React 的 useEffect 需要显式传递依赖数组)。当 refreactive 对象的值发生变化时,Vue 的响应式系统能够精确通知到依赖于该数据的每一个副作用(Effect),包括组件的重新渲染、computed 属性的重新计算、watch 回调的执行等。这种"自动追踪、精确触发"的机制,使得 Vue 在大多数场景下能够实现 O(1) 的更新复杂度 ——即更新成本与受影响的节点数量成正比,而非与组件树的总规模成正比。

1.3 核心差异一览

维度 React Fiber Vue 响应式系统
核心差异 UI = f(state),通用运行时调度 数据驱动视图,编译时优化 + 响应式追踪
更新粒度 组件级别(需要 diff 确定实际变更) 属性级别(精确追踪依赖)
调度模型 协作式多任务(Cooperative Scheduling) 依赖触发式(Dependency-driven)
可中断性 原生支持(Time Slicing) 需配合 nextTick 批量处理
编译角色 次要(JSX 转译) 核心(模板编译 + 优化标记生成)
内存模型 双缓冲(Current / WorkInProgress 两棵树) 代理对象 + Effect 依赖图
学习曲线 中等(需理解 hooks 规则、闭包陷阱) 平缓(模板语法直观)

二、React Fiber:在单线程世界里的调度器

2.1 Stack Reconciler 的困局

在 React 16 之前的 Stack Reconciler 时代,React 的更新过程可以简单概括为 "一撸到底" 。当组件状态发生变化时,React 会从根节点开始,递归遍历整棵组件树,计算新的虚拟 DOM 树,与旧的树进行 Diff,最后一次性将所有变更提交到真实 DOM。这个过程完全 同步不可中断 ——一旦开始,就必须等到全部完成才能将控制权交还给浏览器。对于小型应用,这种方式工作得很好,因为整个更新过程可能只需要几毫秒。但随着应用规模的增长,组件树可能包含数千个节点,一次完整的 reconciliation 可能消耗数十甚至上百毫秒,直接阻塞浏览器的主线程。

这种阻塞带来的用户体验问题是灾难性的。我们想象一下,用户在搜索框中输入文字,同时后台正在接收实时数据更新。在 Stack Reconciler 中,数据更新触发的重渲染可能会完全占用主线程 100ms,在这段时间内,用户的键盘输入事件被挂在事件队列中无法得到响应——用户会感觉"卡顿"。更严重的是,动画在这一期间完全停滞,因为浏览器没有机会执行 requestAnimationFrame 回调。React 团队意识到,问题的根源不在于虚拟 DOM 本身,而在于 JavaScript 的执行模型 ——调用栈是 后进先出的、不可抢占的数据结构,一旦进入深层递归,就没有优雅的方式来"暂停"当前工作去处理更紧急的任务。

2.2 Fiber 的创新:重新实现调用栈

React Fiber 的创新点在于它对这一底层问题的回应:如果浏览器的调用栈不够灵活,那就自己实现一个。Fiber 架构的本质是一种 用户空间调度器,它将原本由 JS 引擎管理的调用栈转换为显式维护的链表数据结构。每一个 React 组件实例不再只是一个函数调用,而是一个持久化的 Fiber 节点对象,其中包含了 child(第一个子节点)、sibling(下一个兄弟节点)和 return(父节点)三个指针,构成了一棵可任意遍历、暂停和恢复的树形链表。

这种数据结构的选择绝非偶然。链表结构使得 React 可以彻底放弃递归(recursion),改用循环(loop)来遍历组件树。在循环的每一次迭代中,React 处理一个 Fiber 节点,然后检查当前帧的剩余时间。如果剩余时间不足(React 默认设置了一个约 5ms 的帧预算),或者检测到有更高优先级的更新到来,React 可以立即保存当前的工作进度(记录下一个待处理的 Fiber 节点引用),将控制权交还给浏览器,然后在下一帧的 requestIdleCallback 回调中无缝恢复工作。Andrew Clark 将 Fiber 描述为 "一个专门用于 React 组件的虚拟栈帧" ——它的核心优势在于,这些栈帧存储在堆内存中,React 可以完全控制它们的执行顺序和时机,这是操作系统调用栈所不具备的能力。

2.3 双缓冲架构与两阶段提交

Fiber 架构引入了 双缓冲 的内存模型,这是另一个深刻影响 React 更新行为的创新。React 在内存中同时维护两棵 Fiber 树:一棵是 current 树,代表了当前屏幕上真实 UI 的状态;另一棵是 workInProgress 树,用于进行正在进行的渲染计算。当更新触发时,React 并不会直接修改 current 树,而是基于它克隆出一棵 workInProgress 树,所有的 reconciliation 工作都在这棵"草稿"树上进行。这个设计的精妙之处在于 渲染过程完全不会影响用户看到的界面——即使渲染过程中途被中断或完全丢弃,用户看到的依然是 current 树对应的一致 UI。

workInProgress 树的所有工作完成后,React 进入 提交阶段(Commit Phase)。这是一个 同步、不可中断 的阶段,React 将 workInProgress 树的所有副作用(DOM 插入、更新、删除,以及生命周期函数和 useEffect 回调的调度)一次性应用到真实 DOM 上,然后原子性地将 workInProgress 树切换为新的 current 树。两阶段架构的严格分离是 React 并发特性的基石:渲染阶段(Render Phase)可以被打断和重启,因为它只操作内存中的 workInProgress 树;提交阶段(Commit Phase)必须是原子的,因为此时正在修改用户可见的界面,任何不一致都会导致视觉闪烁。

graph TD
    A[状态更新触发] --> B{是否有更高<br/>优先级任务?}
    B -->|是| C[保存当前进度<br/>yield 控制权]
    C --> D[处理高优先级任务]
    D --> E[恢复之前工作]
    E --> B
    B -->|否| F[Render Phase<br/>构建 workInProgress 树]
    F --> G[生成 Effect List]
    G --> H[Commit Phase<br/>同步提交 DOM 变更]
    H --> I[切换 current 指针]
    I --> J[调度 useEffect]

2.4 优先级调度与 Lane 模型

React 18 进一步演化出了 Lane 优先级模型,用 31 位的二进制数来表示不同类型的更新优先级。每一位代表一个"通道"(Lane),不同的交互类型(用户输入、点击、数据加载、过渡动画等)被分配到不同的 Lane 上。React 可以精确判断哪些更新更紧急,并支持 Lane 的"纠缠"(entanglement)机制——当高优先级更新和低优先级更新之间存在数据依赖时,React 会自动将它们合并渲染,防止出现视觉不一致。这种精细的优先级控制系统使得 React 能够在极端复杂的并发场景中依然保持用户交互的流畅性,但也显著增加了框架的运行时复杂度和学习成本。


三、Vue 响应式系统:让数据自己告诉你它变了

3.1 从 defineProperty 到 Proxy:响应式技术的进化

Vue 的响应式系统经历了两代重大演进。Vue 2 使用 Object.defineProperty 为对象的每一个属性定义 getter 和 setter,在属性被读取时收集依赖,在被修改时触发更新。这个方案在当时是创新的,但它有几个根本性缺陷:首先,Object.defineProperty 只能拦截已经存在的属性,无法检测对象的新增属性和数组索引的变化(这也是 Vue 2 需要 Vue.setVue.delete API 的原因);其次,它需要对数据对象进行 深度递归遍历,在初始化时就为每一层嵌套对象的每一个属性都设置 getter/setter,这在处理大型数据对象时会产生大的性能开销。

Vue 3 的响应式系统基于 ES6 的 Proxy 对象进行了彻底重写。与 Object.defineProperty 不同,Proxy 可以拦截对目标对象的 任何操作 ——包括属性读取、赋值、删除、枚举、函数调用、in 运算符,甚至 new 操作。这意味着 Vue 3 不再需要深度递归初始化:代理是"懒"的,只有当访问到某个嵌套对象时,才会递归地为该对象创建代理。更重要的是,Proxy 让 Vue 3 天然支持 Map、Set、WeakMap、WeakSet 等 ES6 数据结构,以及数组的所有操作(包括直接通过索引赋值和修改 length),无需任何特殊处理。

在 Vue 3 的源码中,reactive() 函数通过 new Proxy(target, mutableHandlers) 创建响应式对象,其中 mutableHandlers 包含了 getset 拦截器。get 拦截器使用 Reflect.get(target, key, receiver) 读取属性值(Reflect API 的设计目的正是为了与 Proxy 配合使用,提供更完整和规范的元编程能力),同时调用 track() 函数进行依赖收集;set 拦截器使用 Reflect.set() 写入新值,然后调用 trigger() 函数通知所有依赖进行更新。这种 Proxy + Reflect 的组合已经成为现代 JavaScript 元编程的标准范式。

3.2 依赖收集的三剑客:TargetMap、Dep、Effect

Vue 3 的响应式系统内部维护了一个精巧的全局依赖追踪结构。其核心是三个关键数据结构:

首先是 targetMap,一个 WeakMap<object, Map<string | symbol, Set<ReactiveEffect>>> 结构。它的作用是建立"响应式对象 -> 属性键 -> 依赖集合"的三层映射。WeakMap 的选择非常重要——它允许垃圾回收器在响应式对象不再被引用时自动回收其对应的依赖信息,防止内存泄漏。当 track() 被调用时,Vue 会根据当前被访问的响应式对象和属性键,找到或创建对应的依赖集合(Dep),然后将当前正在执行的 ReactiveEffect 实例添加到这个集合中。

其次是 ReactiveEffect 类,它是 Vue 响应式系统中"副作用"的抽象表示。组件的渲染函数、computed 属性的计算函数、watch 的回调函数,本质上都是 ReactiveEffect 的不同实例。每个 ReactiveEffect 有一个 run() 方法用于执行副作用,以及一个 deps 数组用于记录它依赖于哪些 Dep 集合。这种 双向记录 的机制——Effect 记录它依赖了哪些 Dep,Dep 记录哪些 Effect 依赖了它——是实现精确更新的关键。当响应式数据变化时,trigger() 函数只需要找到对应的 Dep 集合,遍历其中的所有 Effect 并重新执行即可。

最后是调度器(Scheduler)。Vue 并不会在数据变化时立即同步执行所有副作用,而是将它们推入一个队列,通过 nextTick 机制进行 异步批量刷新。这意味着在同一个事件循环中发生的多个数据变化,只会触发一次统一的 DOM 更新——这是 Vue 性能优化的重要手段。通过 Promise.then(或降级到 setImmediate / setTimeout),Vue 确保所有同步的数据变更都完成后,才在下一个微任务中执行副作用,这种批量处理策略大幅减少了不必要的重复渲染。

graph LR
    A[响应式对象 Proxy] -->|读取属性| B[track]
    B --> C{targetMap}
    C -->|对象| D[Map: key -> Dep]
    D -->|属性| E[Set of Effects]
    E -->|添加| F[当前 Effect]
    
    G[修改属性] -->|触发| H[trigger]
    H --> C
    D -->|获取 Effects| I[批量调度更新]
    I -->|nextTick| J[执行 Effect/DOM更新]

3.3 编译器的智慧:从模板到优化标记

Vue 的响应式系统之所以高效,很大程度上归功于其 编译器的静态分析能力。与 React 的 JSX 不同,Vue 使用基于 HTML 的模板语法。这种看似限制性的设计实际上为编译器优化打开了巨大的空间。当 Vue 编译器分析一个模板时,它能够识别出哪些部分是 静态的(不会随数据变化),哪些是 动态的(绑定响应式数据)。

Vue 3 的编译器引入了多项革命性的优化技术:静态提升(Static Hoisting)将静态节点从渲染函数中提取出来,只在首次渲染时创建一次,后续更新完全跳过这些节点;Patch Flags 为每一个动态节点打上一个优化标记,精确指示该节点的哪个部分可能变化(文本内容、类名、样式、属性等),这样运行时的 diff 算法可以跳过完整的 props 比较,只检查可能发生变化的特定部分;树扁平化 打破了传统的递归 diff 模式,将所有动态节点收集到一个扁平数组中,diff 时只需要遍历这个数组而非整棵树。这些编译时优化的综合效果,使得 Vue 3 的虚拟 DOM 更新效率远超传统的全树 diff 实现——虽然 Vue 仍然使用虚拟 DOM,但它已经是一个被编译器"武装到牙齿"的高度优化版虚拟 DOM。


四、业内其他框架:百花齐放的方案

4.1 Svelte:编译器即框架

如果说 React 代表了 "运行时最大化" 的极端,那么 Svelte 则代表了 "编译时最大化" 的另一个极端。Svelte 的创造者 Rich Harris 提出了一个激进的问题:如果框架在构建时就知道你的组件会如何变化,那为什么还要在运行时做这些工作。Svelte 的核心架构决策是将框架本身"编译掉"——最终运行在浏览器中的代码,几乎是纯粹的手写 JavaScript DOM 操作,没有虚拟 DOM,没有响应式运行时库,没有 diff 算法。

Svelte 5 进一步引入 Runes(如 $state$derived$effect),将响应式模型从隐式的编译器魔法转变为显式的信号(Signals)机制。编译器分析组件模板中的每一个响应式绑定,生成精确的 DOM 更新代码。当 $state 的值变化时,编译生成的代码直接调用 textNode.data = newValueelement.setAttribute('class', newClass),没有任何中间抽象层。这种架构的代价是 Svelte 需要一个功能强大的编译器来处理各种边界情况,但它的回报也是巨大的:Svelte 应用的运行时体积极其微小(约 2-3 KB gzip),更新性能接近原生 JavaScript,内存占用也远低于虚拟 DOM 方案。

4.2 SolidJS: Signals 驱动的细粒度响应式

SolidJS 的创造者 Ryan Carniato 将"细粒度响应式"(Fine-grained Reactivity)推向了一个极致。Solid 同样不使用虚拟 DOM,但它与 Svelte 的编译器驱动方式有所不同:Solid 保留了 JSX 语法,其编译器将 JSX 转换为高效的 DOM 创建和更新指令,而响应式追踪则在运行时通过 Signals 完成。Solid 的 createSignal 返回一个 getter/setter 对,当在 JSX 或其他响应式上下文中读取 signal 时,依赖关系被自动建立;当 signal 值变化时,只有直接依赖于该值的 DOM 节点会被更新。

SolidJS 的一个关键设计特点是 组件函数只执行一次。这与 React(组件函数在每次渲染时都重新执行)和 Vue(渲染函数在每次更新时重新执行)有着根本不同。在 Solid 中,组件的 setup 代码只在挂载时运行一次,后续所有的更新都通过信号系统精确到达对应的 DOM 节点,无需重新执行组件函数。这种设计消除了"重新渲染"的概念,从根本上避免了虚拟 DOM 方案中因组件重渲染而产生的计算开销。在 js-framework-benchmark 中,SolidJS consistently 排名最靠前,与原生 JavaScript 的性能差距极小,这验证了细粒度响应式架构在性能上的巨大潜力。

4.3 Angular:从 Zone.js 到 Signals 的转变

Angular 作为一个历史悠久的企业级框架,其架构演进代表了另一个维度的思考。长期以来,Angular 依赖 Zone.js 进行变化检测——Zone.js 通过猴子补丁(monkey-patching)浏览器的所有异步 API(setTimeout、Promise、XHR、DOM 事件等),在任何异步操作完成后自动触发 Angular 的全局变化检测。这种方案的优点是开发者完全不需要关心何时触发更新——任何异步操作后 Angular 都会自动检查所有组件是否需要更新;缺点是性能极差,因为即使是最微小的状态变化,也可能导致整个组件树的脏检查(Dirty Checking)。

Angular 16+ 开始引入 Signalssignal()computed()effect()),标志着 Angular 正在从 Zone.js 的全局脏检查模型向细粒度响应式模型迁移。Angular 的 Signals 设计与 SolidJS 类似,但提供了更渐进式的迁移路径——开发者可以逐步将组件从 Zone.js 迁移到 Signals,而无需重写整个应用。Angular 的转变印证了一个行业趋势:细粒度响应式正在成为前端框架的共识方向,即使是传统上采用完全不同架构的框架也在向这一方向靠拢。

4.4 各框架架构对比

框架 渲染策略 响应式模型 运行时体积 更新粒度 编译角色
React 19 Virtual DOM + Fiber Hooks + 自动 memoization ~45 KB 组件级 React Compiler (构建时 memo)
Vue 3 Compiler-optimized VDOM Proxy + Effect 追踪 ~34 KB 属性级 核心(静态提升、Patch Flags)
Vue Vapor 无 VDOM(直接 DOM) Proxy + Effect 追踪 ~10 KB 属性级 核心(编译为 DOM 操作)
Svelte 5 无 VDOM(编译后代码) Runes (Signals) ~3 KB 语句级 核心(编译器即框架)
SolidJS 无 VDOM(编译后代码) Signals (createSignal) ~7 KB DOM 节点级 JSX 编译 + 运行时追踪
Angular 19 incremental DOM Signals (迁移中) ~120 KB 属性级 AOT 编译 + Signals

前端框架运行时性能对比

前端框架 Bundle 体积对比

前端框架架构演进时间线


五、两种框架什么场景下使用(不一定对)

5.1 性能特性的场景化分析

React Fiber 的并发调度能力在大规模、高频率、高并发更新的场景下展现出独特优势。比如一个复杂的股票交易仪表盘:页面上有数十个实时数据流(价格、成交量、订单深度),同时用户正在与图表交互(缩放、平移、选择时间范围)。React 的 Fiber 架构允许 用户交互(高优先级)实时打断数据更新(低优先级),确保图表操作始终保持 60fps 的流畅度,而价格数据在后台以较低优先级逐步更新。如果没有 Fiber 的调度能力,大量数据更新可能导致用户交互出现明显卡顿。React Compiler(原 React Forget)进一步通过编译时自动插入 memoization 来减少不必要的重渲染,让开发者不再需要手动管理 useMemouseCallback

Vue 的响应式系统在大多数常规应用场景下提供更优异的更新效率和开发体验。由于 Vue 精确追踪了每一个数据属性与视图之间的依赖关系,更新成本天然地与变更的影响范围成正比,而非与组件树的总规模成正比。这意味着在一个包含 1000 个组件的页面中,如果只有底部一个计数器发生变化,Vue 只需要更新那个计数器对应的 DOM 节点,而 React(在没有 Compiler 优化的情况下)可能需要重新渲染整个受影响的组件子树,然后进行 diff。Vue 3.6 的 Vapor Mode 进一步将这一优势推向极致:对于使用 Composition API 的组件,Vapor Mode 可以在编译时直接生成 DOM 操作代码,完全跳过虚拟 DOM,实现与 SolidJS 媲美的性能。

5.2 开发者体验:心智模型与学习曲线

React 的编程模型更接近 JavaScript 的函数式编程范式。Hooks(useStateuseEffectuseMemo 等)的引入虽然解决了类组件的逻辑复用问题,但也带来了新的心智负担:hooks 的调用顺序必须严格一致(不能在条件语句中调用),依赖数组需要手动维护(遗漏依赖会导致 bug),闭包陷阱(stale closure)是新手最常遇到的问题之一。React 的灵活性是一把双刃剑——它允许你以几乎任何方式组织代码,但也意味着团队需要建立严格的代码规范来保持一致性。React Compiler 的出现正在缓解这些问题,通过编译时自动优化替代了大部分手动 memoization 的工作。

Vue 的编程模型则更加 约定优于配置(Convention over Configuration)。模板语法({{ }}v-ifv-forv-bind)对前端开发者来说非常直观,因为它们本质上就是增强的 HTML。Composition API 提供了与 React Hooks 类似的逻辑组合能力,但没有了调用顺序的限制,也没有了依赖数组——因为 Vue 的响应式系统 自动追踪依赖,开发者不需要手动声明。这种"自动依赖追踪"的设计极大地减少了与响应式相关的 bug。对于初学者来说,Vue 的渐进式设计意味着可以从一个简单的 script 标签引入开始,逐步学习到完整的单文件组件(SFC)、Composition API、状态管理(Pinia)和路由(Vue Router),每一步都有明确的指导路径。

5.3 生态

React 的生态系统无疑是前端领域最为庞大和成熟的。从状态管理(Redux、Zustand、Jotai、Recoil)到路由(React Router)、元框架(Next.js、Remix)、UI 组件库(Material-UI、Ant Design、Chakra UI)、表单处理(React Hook Form、Formik)、数据获取(TanStack Query、SWR),React 生态几乎覆盖了前端开发的每一个细分领域。Next.js 的 App Router 和 React Server Components (RSC) 代表了 React 生态在服务端渲染和全栈开发方向上的最新探索。对于大型企业和团队来说,React 生态的广度和深度意味着几乎任何需求都能找到成熟的解决方案,招聘拥有 React 经验的开发者也相对容易。

Vue 的生态系统虽然规模不及 React,但其 整合度更高、一致性更好。Vue 官方维护的核心生态库(Vue Router、Pinia、Vite、VueUse、Nuxt.js)在 API 设计和发布节奏上保持高度统一,这大大降低了开发者在不同库之间切换的认知成本。Nuxt.js 作为 Vue 的官方元框架,提供了开箱即用的服务端渲染、静态生成、API 路由、自动导入等全栈功能,其开发者体验在很多方面优于 Next.js。


六、融合的未来:架构趋同与各自进化

6.1 从对立到融合的行业趋势

一个值得思考的现象是:React 和 Vue 虽然起源于完全不同的架构哲学,但是在最近的两年里,两条技术路线正在呈现出 趋同。React 通过 React Compiler 在编译时自动完成原本需要手动进行的 memoization 优化,实质上是在"借用"编译时优化的思路来弥补虚拟 DOM 的性能缺陷;Vue 通过 Vapor Mode 探索无虚拟 DOM 的编译策略,实质上是在向 Svelte/Solid 的细粒度响应式范式靠拢。两者都在向对方擅长的领域延伸——React 增强编译时能力,Vue 增强运行时调度能力。

这种趋同也不是偶然,而是前端架构演进的必然结果。无论是虚拟 DOM 还是细粒度响应式,最终目标都是"在状态变化时高效地更新界面"。虚拟 DOM 的方案通过"通用运行时 diff"解决问题,优点是灵活性和可预测性,缺点是运行时开销;细粒度响应式的方案通过"编译时/运行时精确追踪"解决问题,优点是极致的性能,缺点是更强的编译依赖和对编程模式的约束。最优的架构必然是在两者之间找到平衡点——利用编译器做尽可能多的静态分析和优化,同时保留运行时的调度能力来处理动态和不可预测的场景。

6.2 React 的未来:Compiler + Server Components

React 团队正在全力推进三个方向的进化:React Compiler(构建时自动 memoization)、React Server Components(服务端组件,零客户端 bundle)、以及 Offscreen Rendering(离屏渲染,用于预加载和保留组件状态)。React Compiler 的成熟将从根本上改变 React 的性能优化范式——开发者不再需要手动编写 useMemouseCallbackReact.memo,编译器会在构建时自动完成这些优化,且粒度通常比手动优化更细。React Server Components 则代表了 React 对"如何减少客户端 JavaScript 体积"这一问题的回答:将纯数据展示型组件放在服务端执行,只将交互型组件发送到客户端。这种服务器优先的架构(Server-first Architecture)正在通过 Next.js 的 App Router 成为 React 生态的主流范式。

6.3 Vue 的未来:Vapor Mode + Alien Signals

Vue 的未来路线图同样清晰而且让人期待。Vapor Mode 的目标是让 Vue 在保持现有 API 不变的前提下,实现 SolidJS 级别的渲染性能——通过在编译时生成直接 DOM 操作代码,完全跳过虚拟 DOM。这意味着 Vue 开发者无需改变任何编程习惯,只需开启一个编译器选项,就能获得数倍的渲染性能提升。Vue 3.6 还在开发 Alien Signals——一套与框架无关的信号系统实现,旨在让 Vue 的响应式原语可以与其他信号库互操作。长期来看,Vue 的架构愿景是成为一个可适应不同场景的灵活系统:对于简单场景,Vapor Mode 提供极致性能;对于复杂场景,编译器优化的虚拟 DOM 提供完整的特性支持;响应式系统作为独立模块,可以与任何渲染层配合使用。

七、总结

React Fiber 和 Vue 响应式系统代表了前端架构设计中两种取向。React 选择了一条更接近计算机科学底层的路:重新设计调用栈,实现用户空间调度器,以通用性和灵活性为代价,换来了对极端并发场景的掌控力。Vue 选择了一条更接近应用开发本质的路:让数据自己说话,让编译器做苦力,以更强的编译时约束为代价,换来了大多数场景下的高效和优雅。

这两种选择没有高下之分,它们是前端技术生态的 阴阳两面——一方的创新会激发另一方的进化。Fiber 的并发调度启发了 Vue 对异步更新队列的重构;Vue 的编译器优化启发了 React Compiler 的方向;Svelte 的编译器范式启发了 Vue Vapor Mode 的探索;SolidJS 的细粒度响应式启发了 Angular Signals 的迁移。

这种跨框架的相互启发和借鉴,恰恰说明前端架构的进化不是线性的,而是辩证的。每一个看似对立的技术选择,实际上都在推动整个行业向前发展。React 的 Fiber 证明了在 JavaScript 单线程环境中实现复杂调度的可行性;Vue 的编译器证明了静态分析在现代 UI 框架中的巨大价值;Svelte 的编译器范式证明了"没有运行时"的可能性;SolidJS 的 Signals 证明了细粒度响应式的性能极限。这些探索共同构成了前端技术栈的知识积累,无论最终哪个框架占据主流,整个行业都从中受益。

[前端]单文件上传组件

本文介绍了一个单文件上传前端组件,基于Vue3、ElementPlus,提供了组件源码及使用示例教程,可供参考和使用。

支持的功能:文件覆盖、限制文件类型、最大文件大小

组件源码

<!--
  * 单文件上传组件
  * 
  * Author: GFire
  * Date: 2025/01/16
-->
<template>
  <div>
    <el-upload
      ref="upload"
      :limit="1"
      :accept="props.accept"
      :on-exceed="handleExceed"
      :on-change="handleChange"
      :on-remove="handleRemove"
      :auto-upload="false"
    >
      <!-- 默认插槽,用于放置触发文件选择的元素,如按钮、文字等 -->
      <slot name="default"></slot>
      <template #tip>
        <div style="font-size: 12px; color: var(--el-color-info)">
          <div v-if="props.accept">支持的文件类型:{{ props.accept }}</div>
          <div v-if="props.maxFileSize">支持的最大文件大小:{{ props.maxFileSize.size + props.maxFileSize.unit }}</div>
        </div>
        <!-- 用户自定义提示内容插槽 -->
        <slot name="tip"></slot>
      </template>
    </el-upload>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ElNotification, genFileId } from 'element-plus';
import type { UploadInstance, UploadProps, UploadRawFile, UploadFile } from 'element-plus';

type SizeUnit = 'KB' | 'MB' | 'GB';

const props = defineProps<{
  /**
   * 接受上传的文件类型,以文件后缀用逗号拼接的字符串,如:`.jpg,.txt,.xlsx`,不传则无限制
   */
  accept?: string;
  /**
   * 支持的最大文件大小,不传则无限制
   */
  maxFileSize?: { size: number; unit: SizeUnit };
}>();

const emit = defineEmits<{
  (event: 'fileChange', file?: File): void;
}>();

defineExpose({
  /**
   * 清空文件列表
   */
  clearFile() {
    upload.value!.clearFiles();
  },
});

const upload = ref<UploadInstance>();
let tempFile: UploadFile | undefined;

// 覆盖前一个文件
const handleExceed: UploadProps['onExceed'] = (files) => {
  upload.value!.clearFiles();
  const file = files[0] as UploadRawFile;
  file.uid = genFileId();
  upload.value!.handleStart(file);
};

function handleChange(uploadFile: UploadFile) {
  if (!isValidFile(uploadFile)) {
    // 文件不合法,回退
    rollback();
  } else {
    tempFile = uploadFile;
    emit('fileChange', uploadFile.raw);
  }
}

// 校验文件是否合法
function isValidFile(uploadFile: UploadFile) {
  if (!isValidFileType(uploadFile)) {
    ElNotification.error({
      title: '文件不合法',
      message: `文件类型不支持,需为:${props.accept}`,
      position: 'top-right',
    });
    return false;
  }

  if (!isValidFileSize(uploadFile)) {
    ElNotification.error({
      title: '文件不合法',
      message: `文件大小超过限制:${props.maxFileSize?.size} ${props.maxFileSize?.unit}`,
      position: 'top-right',
    });
    return false;
  }

  return true;
}

function rollback() {
  if (tempFile) {
    upload.value!.clearFiles();
    upload.value!.handleStart(tempFile.raw!);
  } else {
    upload.value!.clearFiles();
  }
}

function handleRemove() {
  tempFile = undefined;
  emit('fileChange', undefined);
}

const acceptTypes = props.accept?.split(',');
function isValidFileType(uploadFile: UploadFile) {
  // 无值,代表接受任意文件类型
  if (!acceptTypes) {
    return true;
  }

  const fileType = '.' + uploadFile.name.split('.').pop();
  for (let type of acceptTypes) {
    if (fileType === type) {
      return true;
    }
  }
  return false;
}

function isValidFileSize(uploadFile: UploadFile) {
  // 无值,代表文件大小无限制
  if (!props.maxFileSize) {
    return true;
  }

  let bytes = convertToBytes(props.maxFileSize.size, props.maxFileSize.unit);
  if (uploadFile.raw!.size > bytes) {
    return false;
  } else {
    return true;
  }
}

function convertToBytes(size: number, unit: SizeUnit) {
  const unitMapping = {
    KB: 1024,
    MB: 1024 * 1024,
    GB: 1024 * 1024 * 1024,
  };

  const multiplier = unitMapping[unit];
  if (multiplier) {
    return size * multiplier;
  } else {
    throw new Error('Unsupported unit. Please use KB, MB, or GB.');
  }
}
</script>

<style scoped></style>

使用示例

示例代码:

<template>
    <SingleFileUpload
      style="width: 300px"
      ref="fileUploadRef"
      accept=".md,.txt"
      :maxFileSize="{ size: 50, unit: 'KB' }"
      @fileChange="handleFileChange"
    >
      <el-button>选择文件</el-button>
      <template #tip> 请上传符合要求的文件 </template>
    </SingleFileUpload>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue';
import SingleFileUpload from '@/components/base/SingleFileUpload.vue';

const fileUploadRef = ref();
// 接收文件变更
function handleFileChange(file: File | undefined) {
  form.file = file;
}

const form = reactive({
  file: undefined as File | undefined,
});

function submitForm() {
  // 模拟提交表单
  console.log('提交表单:', form);

  // 清空文件
  fileUploadRef.value.clearFile();
}
</script>

代码解释:

  • accept=".md,.txt":指定只接受md、txt的文件
  • :maxFileSize="{ size: 50, unit: 'KB' }":指定支持的最大文件大小为50KB
  • @fileChange="handleFileChange":文件变化事件处理

显示效果:

image.png

选择文件,默认限制为提供的文件类型(md、txt):

image.png

选择文件后的效果:

image.png

当选择的文件大小超过限制,则提示异常:

image.png

当选择的文件类型不支持,则提示异常:

image.png

❌