阅读视图

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

Vue3 + Vite 性能优化实战

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

(2)字体优化

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2. 常见避坑点(必看)

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

六、结尾互动

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

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

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

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

vue3使用jsx语法详解

虽然最早是由 React 引入,但实际上 JSX 语法并没有定义运行时语义,并且能被编译成各种不同的输出形式。如果你之前使用过 JSX 语法,那么请注意 Vue 的 JSX 转换方式与 React 中 JSX 的转换方式不同,因此你不能在 Vue 应用中使用 React 的 JSX 转换。与 React JSX 语法的一些明显区别包括:

  • 可以使用 HTML attributes 比如 class 和 for 作为 props - 不需要使用 className 或 htmlFor
  • 传递子元素给组件 (比如 slots) 的方式不同

添加的配置

1️⃣ tsconfig

{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "vue"
  }
}

2️⃣ vite.config.ts

import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

export default {
  plugins: [vue(), vueJsx()]
}

代码演示

vue文件

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

const count = ref(0)

// 1. 定义一个 JSX 片段或小组件
const RenderHeader = () => (
  <header>
    <h2>这是 JSX 渲染的标题</h2>
    <p>当前计数: {count.value}</p>
  </header>
)

// 2. 这是一个返回 VNode 的计算属性。搭配 component 使用
const renderContent = computed(() => {
  return count.value > 5 ? (
    <span>已达到上限</span>
  ) : (
    <button onClick={() => count.value++}>增加</button>
  )
})

// 3. 普通组件, setup返回一个渲染函数
const Bbb = defineComponent({
  name: 'Bbb',
  setup() {
    return () => <div>11111</div>
  },
})
</script>

<template>
  <RenderHeader />
  <component :is="renderContent" />
  <Bbb />
</template>

注意:lang的值是 tsx

tsx文件

// 函数式组件
export default () => {
  return <div class={styles.name}>hello world</div>
}

export const Aaa = defineComponent({
  setup() {
    const t = ref(Date.now())
    // 返回渲染函数
    return () => <div>aaa {t.value}</div>
  },
})

样式方案选型

使用 JSX/TSX,CSS ModulesTailwind CSS 是更好的搭档。Scoped CSS 是专为 Template 设计的。

在 vue文件 中,使用 CSS Modules

<style module>
.header {
  color: blue;
}

.content {
  color: green;
}

.bbb {
  color: red;
}
</style>

eslint

要在 vue文件 中使用tsx,应添加 configureVueProject 的配置

configureVueProject({ scriptLangs: ['ts', 'tsx'] })

export default defineConfigWithVueTs(
  {
    name: 'app/files-to-lint',
    files: ['**/*.{ts,mts,tsx,vue}'],
  },

  globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),

  pluginVue.configs['flat/essential'],
  vueTsConfigs.recommended,
  skipFormatting,
)

参考

在 VS Code中,vue2-vuex 使用终于有体验感增强的插件了。

Vuex Helper

适用于 Vuex 2 的 VS Code 插件,提供 跳转定义代码补全悬浮提示 功能。支持 State, Getters, Mutations 和 Actions。

引言

在 AI 时代,为什么要搞一个老掉牙的 vue2 的 vuex 增强插件?可以想象,现在起步应该都会是 vue3 或者 react 的框架。但老项目永远不会少,除非下定决心去重构,否则永远都要面对老项目,那在vscode中,遇到 vue2 项目的调试过程中,vuex 的跳转定义永远是我开发与迭代时遇到的痛点,AI 给了我机会,让我无需在繁重的业务需求之外,额外耗费太多的时间去学习插件怎么使用,而直接上手去把我的思路交予实现。感谢 AI,让我有能力去完成一些平时不可及的小事情。

功能特性

1. 跳转定义 (Go to Definition)

从组件中直接跳转到 Vuex Store 的定义处。

演示:跳转定义

jump_definition.gif

  • 支持: this.$store.state/getters/commit/dispatch
  • Map 辅助函数: mapState, mapGetters, mapMutations, mapActions
  • 命名空间: 完美支持 Namespaced 模块及其嵌套。

2. 智能代码补全 (Intelligent Code Completion)

智能提示 Vuex 的各种 Key 以及组件中映射的方法。

演示:智能补全

auto_tips_and_complete_for_var.gif

auto_tips_and_complete_for_func.gif

  • 上下文感知: 在 dispatch 中提示 Actions,在 commit 中提示 Mutations。
  • 命名空间过滤: 当使用 mapState('user', [...]) 时,会自动过滤并仅显示 user 模块下的内容。
  • 组件映射方法: 输入 this. 即可提示映射的方法(例如 this.increment 映射自 ...mapMutations(['increment']))。
  • 语法支持: 支持数组语法和对象别名语法 (例如 ...mapActions({ alias: 'name' }))。

3. 悬浮提示与类型推导 (Hover Information & Type Inference)

无需跳转即可查看文档、类型详情。

演示:悬浮文档

hover_info_and_type_inference.gif

  • JSDoc 支持: 提取并显示 Store 定义处的 /** ... */ 注释文档。
  • State 类型: 在悬浮提示中自动推导并显示 State 属性的类型 (例如 (State) appName: string)。
  • 详细信息: 显示类型(State/Mutation等)及定义所在的文件路径。
  • 映射方法: 支持查看映射方法的 Store 文档。

4. Store 内部调用 (Store Internal Usage)

同样支持在 Vuex Store 内部 代码补全、跳转、悬浮提示。

演示:Store 内部 代码补全、跳转、悬浮提示

internal_usage.gif

  • 模块作用域: 当在模块文件(如 user.js)中编写 Action 时,commitdispatch 的代码补全会自动过滤并仅显示当前模块的内容。

同样支持在 Vuex Store 内部 代码补全、跳转、悬浮提示。

支持的语法示例

  • 辅助函数 (Helpers):
    ...mapState(['count'])
    ...mapState('user', ['name']) // 命名空间支持
    ...mapActions({ add: 'increment' }) // 对象别名支持
    ...mapActions(['add/increment'])
    
  • Store 方法:
    this.$store.commit("SET_NAME", value);
    this.$store.dispatch("user/updateName", value);
    
  • 组件方法:
    this.increment(); // 映射自 mapMutations
    this.appName; // 映射自 mapState
    

使用要求

  • 使用 Vuex 的 Vue 2 项目。
  • Store 入口位于 src/store/index.jssrc/store/index.ts(支持自动探测)。
  • 若无法自动找到,请在设置中配置 vuexHelper.storeEntry

配置项

  • vuexHelper.storeEntry: 手动指定 Store 入口文件路径。支持:
    • 别名路径: @/store/index.js (需在 jsconfig/tsconfig 中配置)
    • 相对路径: src/store/index.js
    • 绝对路径: /User/xxx/project/src/store/index.js

更新日志

0.0.1

初始版本,支持功能:

  • 全面支持 State, Getters, Mutations, Actions
  • 支持命名空间过滤 (Namespace Filtering)
  • 支持 JSDoc 悬浮文档显示

不要在简历上写精通 Vue3?来自面试官的真实劝退

image.png

最近在面试,说实话,每次看到 精通 这俩字,我这心里就咯噔一下。不是我不信你,是这俩字太重了。这不仅仅是自信,这简直就是给面试官下战书😥。

你写 熟悉,我问你 API 怎么用,能干活就行。

你写 精通,那我身体里的胜负欲瞬间就被你点燃了:既然你都精通了,那咱们就别聊怎么写代码了,咱们聊聊尤雨溪写这行代码时在想啥吧😒。

结果呢?三个问题下去,我看对面兄弟的汗都下来了,我都不好意思再问。

今天真心给大伙提个醒,简历上这 精通 二字,就是个巨大的坑,谁踩谁知道。

来,我给你们复盘一下,什么叫面试官眼里的精通。

你别只背八股文

我上来通常先问个简单的热身:

Vue3 到底为啥要用 Proxy 换掉 Object.defineProperty?

大部分人张口就来:因为 defineProperty 监听不到数组下标,还监听不到对象新增属性。Proxy 啥都能拦,所以牛逼。

这话错没错?没错。

但这只是 60 分的回答,属于背诵全文🤔。

敢写精通的,你得这么跟我聊:

老哥,其实数组和新增属性那都是次要的。最核心的痛点是 性能,特别是初始化时候的性能。

Vue2 那个 defineProperty 是上来就得递归,把你对象里里外外每一层都给劫持了。对象一深,初始化直接卡顿。

Vue3 的 Proxy 是 惰性的。你访问第一层,我劫持第一层;你访问深层,我再临时去劫持深层。我不访问,我就不干活。

而且,这里面还有个 this 指向 的坑。Vue3 源码里用 Reflect.get 传了个 receiver 参数进去,就是为了保证有继承关系时,this 能指对地方,不然依赖收集就乱套了。

能力 Vue2(defineProperty) Vue3(Proxy)
监听对象新增/删除
监听数组索引/length
一次性代理整个对象
性能上限 ❌ 越大越慢 ✅ 更平滑
Map / Set ⚠️ 部分支持
实现复杂度

你要能说到 懒劫持Reflect 的 receiver 这一层,我才觉得你可能看过源码🙂‍↔️。

Diff 算法别光扯最长递增子序列

第二个问题,稍微上点强度:

Vue3 的 diff 算法快在哪?

别一上来就跟我背什么最长递增子序列,那只是最后一步。

你得从 编译阶段 开始聊。

Vue2 是个老实人,数据变了,它就把整棵树拿来从头比到尾,哪怕你那是个静态的写死的 div,它也要比一下。

Vue3 变聪明了,它搞了个 动静分离

在编译的时候,它就给那些会变的节点打上了标记,叫 PatchFlag。这个是文本变,那个是 class 变,都记好了。

等到真要 diff 的时候,Vue3 直接无视那些静态节点,只盯着带标记的节点看。

这就好比老师改卷子,以前是从头读到尾,现在是只看你改过的错题。这效率能一样吗?

这叫 靶向更新。能扯出这个词,才算摸到了 Vue3 的门道。

Ref 的那些坑说一说?

最后问个细节,看你平时踩没踩过坑:

Ref 在模板里不用写 .value,在 reactive 里也不用写。那为啥有时候在 Map 里又要写了呢?

很多人这就懵了:啊?不都是自动解包吗?

精通 的人会告诉我:

Vue 的自动解包是有底线的。

模板里那是亲儿子待遇,帮你解了。

reactive 对象里那是干儿子待遇,get 拦截器里帮你解了。

但是 MapSet 这种数据结构,Vue 为了保证语义不乱,是不敢乱动的。你在 Map 里存个 ref,取出来它还是个 ref,必须得手写 .value。👇

const count = ref(0)

const map = new Map()
map.set('count', count)

map.get('count')        // 拿到的是 ref 对象
map.get('count').value // 这是正确取值

Map / Set / WeakMap 不是 Vue 的响应式代理对象

这种细枝末节,没在真实项目里被毒打过,是很难注意到的。


面试其实就是一场 心理博弈

你写 精通,我对你的预期就是 行业顶尖。你答不上来,落差感太强,直接挂。

你写 熟练掌握 或者 有丰富实战经验,哪怕你答出上面这些深度的 50%,我都觉得这小伙子爱钻研,是个惊喜🥱。

在这个行业里,精通 真的不是终点,而是一个无限逼近的过程。

我自己写了这么多年代码,现在简历上也只敢写 熟练🤷‍♂️。

精通 换成 实战案例 吧,比如 我在项目中重写了虚拟列表,或者 我给 Vue 生态贡献过 PR

这比那两个干巴巴的汉字,有力一万倍。

听哥一句劝,Flag 别乱搞,Offer 自然就会来😒。

你们说呢?

Suggestion.gif

🚀 从DOM操作到Vue3:一个Todo应用的思维革命

🚀 从DOM操作到Vue3:一个Todo应用的思维革命

前言:当我第一次学习前端时,导师让我实现一个Todo应用。我花了2小时写了50行代码,导师看了一眼说:“试试Vue3吧。” 我用30分钟重写了同样的功能,代码减少到20行。那一刻,我明白了什么是真正的数据驱动开发。今天,我想通过这个Todo应用,带你体验这场思维革命。

第一章:传统开发方式的困境

让我们先回顾一下用原生JavaScript实现的Todo应用:

<!-- demo.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>传统Todo应用</title>
</head>
<body>
    <h2 id="app"></h2>
    <input type="text" id="todo-input">
    <script>
        // 传统做法:命令式编程
        const app = document.getElementById('app')
        const todoInput = document.getElementById('todo-input')
        
        // 手动监听事件
        todoInput.addEventListener('change', function(event){
            const todo = event.target.value.trim()
            if(!todo){
                console.log('请输入任务')
                return
            }
            // 手动更新DOM
            app.innerHTML = todo
        })
    </script>
</body>
</html>

🔍 传统方式的三大痛点:

  1. 命令式编程:你需要像指挥官一样告诉浏览器每一步该做什么
  2. DOM操作繁琐:每次数据变化都要手动查找和更新DOM
  3. 关注点错位:80%的代码在处理界面操作,只有20%在处理业务逻辑

这就像每次想改变房间布局,都要亲自搬砖砌墙

第二章:Vue3的数据驱动革命

现在,让我们看看用Vue3实现的完整Todo应用:

<!-- App.vue -->
<template>
  <div>
    <!-- 1. 数据绑定 -->
    <h2>{{title}}</h2>
    
    <!-- 2. 双向数据绑定 -->
    <input 
      type="text" 
      v-model="title" 
      @keydown.enter="addTodo"
      placeholder="输入任务后按回车"
    >
    
    <!-- 3. 条件渲染 -->
    <ul v-if="todos.length">
      <!-- 4. 列表渲染 -->
      <li v-for="todo in todos" :key="todo.id">
        <!-- 5. 双向绑定到对象属性 -->
        <input type="checkbox" v-model="todo.done">
        
        <!-- 6. 动态class绑定 -->
        <span :class="{done: todo.done}">{{todo.title}}</span>
      </li>
    </ul>
    
    <!-- 7. v-else指令 -->
    <div v-else>
      暂无任务
    </div>
    
    <!-- 8. 计算属性使用 -->
    <div>
      进度:{{activeTodos}} / {{todos.length}}
    </div>
    
    <!-- 9. 计算属性的getter/setter -->
    全选<input type="checkbox" v-model="allDone">
  </div>
</template>

<script setup>
// 10. Composition API导入
import { ref, computed, watch } from 'vue'

// 11. 响应式数据
const title = ref("Todos任务清单")
const todos = ref([
  {
    id: 1,
    title: '学习vue',
    done: false
  },
  {
    id: 2,
    title: '打王者',
    done: false
  },
    {
    id: 3,
    title: '吃饭',
    done: true
  }
])

// 12. 计算属性
const activeTodos = computed(() => {
  return todos.value.filter(todo => !todo.done).length
})

// 13. 方法定义
const addTodo = () => {
  if(!title.value) return
  
  todos.value.push({
    id: Date.now(),  // 更好的ID生成方式
    title: title.value,
    done: false
  })
  
  title.value = ""
}

// 14. 计算属性的getter/setter
const allDone = computed({
  get() {
    return todos.value.length > 0 && 
           todos.value.every(todo => todo.done)
  },
  set(val) {
    todos.value.forEach(todo => todo.done = val)
  }
})

// 15. 监听器 - 补充知识点
watch(todos, (newTodos) => {
  console.log('任务列表发生变化:', newTodos)
  // 可以在这里实现本地存储
}, { deep: true })

// 16. 生命周期钩子 - 补充知识点
import { onMounted } from 'vue'
onMounted(() => {
  console.log('组件挂载完成')
  // 可以在这里从本地存储读取数据
})
</script>

<style>
.done {
  color: gray;
  text-decoration: line-through;
}

/* 17. 组件样式作用域 - 补充知识点 */
/* 这里的样式只作用于当前组件 */
</style>

第三章:Vue3核心API深度解析

🎯 1. ref - 响应式数据的基石

代码:

const title = ref("Todos任务清单")

补充:

  • ref用于创建响应式引用
  • 访问值需要使用.value
  • 为什么需要.value?因为Vue需要知道哪些数据需要被追踪变化
// ref的内部原理简化版
function ref(initialValue) {
  let value = initialValue
  return {
    get value() {
      // 这里可以收集依赖
      return value
    },
    set value(newValue) {
      value = newValue
      // 这里可以通知更新
    }
  }
}

🎯 2. v-model - 双向绑定的魔法

代码:

<input type="text" v-model="title">

补充: v-model实际上是语法糖,它等于:

<input 
  :value="title"
  @input="title = $event.target.value"
>

对于复选框,v-model的处理有所不同:

<input type="checkbox" v-model="todo.done">
<!-- 等价于 -->
<input 
  type="checkbox" 
  :checked="todo.done"
  @change="todo.done = $event.target.checked"
>

🎯 3. 指令系统详解

v-show vs v-if

<!-- v-if是真正的条件渲染 -->
<div v-if="show">条件渲染</div> <!-- 会从DOM中移除/添加 -->

<!-- v-show只是控制display -->
<div v-show="show">显示控制</div> <!-- 始终在DOM中,只是display切换 -->

动态参数

<!-- 动态指令参数 -->
<a :[attributeName]="url">链接</a>
<button @[eventName]="doSomething">按钮</button>

🎯 4. computed - 智能计算属性

细节

// 计算属性的缓存特性
const expensiveCalculation = computed(() => {
  console.log('重新计算') // 只有依赖变化时才会执行
  return todos.value
    .filter(todo => !todo.done)
    .map(todo => todo.title.toUpperCase())
    .join(', ')
})

// 依赖没有变化时,直接返回缓存值
console.log(expensiveCalculation.value) // 输出并打印"重新计算"
console.log(expensiveCalculation.value) // 直接返回缓存值,不打印

🎯 5. watch - 数据监听器

重要知识点:

// 1. 监听单个ref
watch(title, (newTitle, oldTitle) => {
  console.log(`标题从"${oldTitle}"变为"${newTitle}"`)
})

// 2. 监听多个数据源
watch([title, todos], ([newTitle, newTodos], [oldTitle, oldTodos]) => {
  // 处理变化
})

// 3. 立即执行的watch
watch(todos, (newTodos) => {
  localStorage.setItem('todos', JSON.stringify(newTodos))
}, { immediate: true }) // 组件创建时立即执行一次

// 4. 深度监听
watch(todos, (newTodos) => {
  // 可以检测到对象内部属性的变化
}, { deep: true })

🎯 6. 生命周期钩子

完整生命周期:

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

onBeforeMount(() => {
  console.log('组件挂载前')
})

onMounted(() => {
  console.log('组件已挂载,可以访问DOM')
})

onBeforeUpdate(() => {
  console.log('组件更新前')
})

onUpdated(() => {
  console.log('组件已更新')
})

onBeforeUnmount(() => {
  console.log('组件卸载前')
})

onUnmounted(() => {
  console.log('组件已卸载')
})

onErrorCaptured((error) => {
  console.error('捕获到子组件错误:', error)
})

第四章:Vue3开发模式的优势

🚀 1. 开发效率对比

功能 传统JS代码量 Vue3代码量 效率提升
数据绑定 10-15行 1行 90%
列表渲染 15-20行 3行 85%
事件处理 5-10行 1行 80%
样式绑定 5-10行 1行 80%

🎯 2. 思维模式转变

传统开发思维(怎么做):

1. 找到DOM元素
2. 监听事件
3. 获取数据
4. 操作DOM更新界面

Vue3开发思维(要什么):

1. 定义数据状态
2. 描述UI与数据的关系
3. 修改数据
4. 界面自动更新

💡 3. 性能优化自动化

Vue3自动为你做了这些优化:

// 1. 虚拟DOM减少真实DOM操作
// 2. Diff算法最小化更新
// 3. 响应式系统精确追踪依赖
// 4. 计算属性缓存避免重复计算
// 5. 组件复用减少渲染开销

第五章:实战技巧与最佳实践

📝 1. 代码组织建议

<script setup>
// 1. 导入部分
import { ref, computed, watch, onMounted } from 'vue'

// 2. 响应式数据
const title = ref('')
const todos = ref([])

// 3. 计算属性
const activeCount = computed(() => { /* ... */ })

// 4. 方法定义
const addTodo = () => { /* ... */ }

// 5. 生命周期
onMounted(() => { /* ... */ })

// 6. 监听器
watch(todos, () => { /* ... */ })
</script>

🎨 2. 样式管理技巧

<style scoped>
/* scoped属性让样式只作用于当前组件 */
.todo-item {
  padding: 10px;
}

/* 深度选择器 */
:deep(.child-component) {
  color: red;
}

/* 全局样式 */
:global(.global-class) {
  font-size: 16px;
}
</style>

🔧 3. 调试技巧

// 1. 在模板中调试
<div>{{ debugInfo }}</div>

// 2. 使用Vue Devtools浏览器插件
// 3. 使用console.log增强
watch(todos, (newTodos) => {
  console.log('todos变化:', JSON.stringify(newTodos, null, 2))
}, { deep: true })

结语:从学习者到实践者

通过这个Todo应用,我们看到了Vue3如何将我们从繁琐的DOM操作中解放出来,让我们能更专注于业务逻辑。这种声明式编程的思维方式,不仅让代码更简洁,也让开发更高效。

记住

  1. Vue3不是魔法,但它让开发变得像魔法一样简单
  2. 学习Vue3不仅是学习一个框架,更是学习一种更好的编程思维
  3. 从今天开始,尝试用数据驱动的方式思考问题

下一步建议

  1. 在Vue Playground中多练习
  2. 阅读Vue3官方文档
  3. 尝试实现更复杂的功能(过滤、搜索、排序)
  4. 学习Vue Router和Pinia

📚 资源推荐

希望这篇文章能帮助你更好地理解Vue3的强大之处!如果你有任何问题或想法,欢迎在评论区讨论交流。🌟

一起进步,从今天开始!

vue3响应式解构注意

reactive 的响应式是深度绑定的(默认递归代理所有嵌套对象),直接解构外层对象得到的嵌套对象,本质还是 reactive 生成的代理对象,因此它本身的响应式不会丢失;但如果对这个嵌套对象再做解构,就会回到之前的问题 —— 解构其属性会丢失响应式。

代码示例(核心验证)

vue

<script setup lang="ts">
import { reactive } from 'vue'
// 创建响应式对象 const user = reactive({ name: '张三', age: 20 }) // 直接解构:丢失响应式 const { name, age } = user  对属性是基本类型时会丢失响应式这时需要用toRefs包裹
// 外层响应式对象,包含嵌套对象
const user = reactive({
  info: { // 嵌套对象,被 reactive 深度代理
    name: '张三',
    age: 20
  },
  hobby: ['篮球', '游戏'] // 嵌套数组,同样被深度代理
})
const user = reactive({ name: '张三', age: 20 }) // 用 toRefs 解构:保留响应式(转为 ref 类型) const { name, age } = toRefs(user) const changeName = () => { name.value = '李四' // 需通过 .value 修改,原对象会同步更新 console.log(user.name) // 输出 "李四" }



直接解构外层对象得到的嵌套对象
// 直接解构外层对象:拿到嵌套对象 info 和 hobby
const { info, hobby } = user

// 场景1:修改解构出的嵌套对象的属性(仍有响应式)
const changeInfo = () => {
  info.name = '李四' // ✅ 有响应式,视图会更新
  hobby.push('看书') // ✅ 有响应式,视图会更新
  console.log(user.info.name) // 输出 "李四"(和原对象同步)
}

// 场景2:对嵌套对象再解构(属性丢失响应式)
const { name, age } = info
const changeName = () => {
  name = '王五' // ❌ 非响应式,TS 提示无法赋值,视图无变化
  console.log(info.name) // 仍然是 "李四"
}

// 场景3:直接替换整个嵌套对象(仍有响应式)
const replaceInfo = () => {
  info.age = 25 // ✅ 改属性:响应式
  // 注意:如果直接替换整个嵌套对象,也需要通过原对象或解构的嵌套对象操作
  user.info = { name: '赵六', age: 30 } // ✅ 响应式
  // 或 info = { name: '赵六', age: 30 } ❌ 错误!解构的 info 是常量,不能直接赋值
}
</script>

<template>
  <div>原对象:{{ user.info.name }} - {{ user.info.age }} | {{ user.hobby }}</div>
  <div>解构嵌套对象:{{ info.name }} - {{ info.age }} | {{ hobby }}</div>
  <div>解构嵌套对象的属性:{{ name }} - {{ age }}</div>
  
  <button @click="changeInfo">修改嵌套对象属性</button>
  <button @click="changeName">修改解构的嵌套属性</button>
  <button @click="replaceInfo">替换嵌套对象</button>
</template>

运行结果:

  • 点击「修改嵌套对象属性」:所有关联视图(原对象、解构的嵌套对象)都会更新;
  • 点击「修改解构的嵌套属性」:视图无变化,嵌套对象的属性也没改;
  • 点击「替换嵌套对象」:原对象和解构的嵌套对象视图都会更新。

二、原理拆解:为什么嵌套对象仍有响应式?

  1. reactive 对对象做深度代理:当创建 reactive({ info: { name: '张三' } }) 时,不仅外层对象被 Proxy 代理,内部的 info 对象也会被递归转为 Proxy 代理对象;
  2. 直接解构 const { info } = user:拿到的 inforeactive 生成的代理对象本身(而非原始值),因此访问 / 修改 info.name 仍会触发响应式的依赖收集和更新;
  3. 解构嵌套对象的属性 const { name } = info:拿到的是 info.name原始值(如字符串 "张三"),而非代理属性,因此丢失响应式。

深入理解 Vue.js 渲染机制:从声明式到虚拟 DOM 的完整实现

相关概念:

命令式 VS 声明式

从范式上来看,视图层框架通常分为:

  • 命令式框架
    • 更加关注过程,代码本身描述的是“做事的过程”,符合逻辑直觉
    •   // 自然语言描述能够与代码产生一一对应的关系
        // 示例:
        const div = document.querySelector('#app'// 获取div
        div.innerText = 'hello world'// 设置文本内容
        div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件
      
  • 声明式框架
    • 更加关注结果,主要是提升代码的可维护性
    •   // 用户提供一个“预期的结果”,中间的过程由vue.js实现
        // 示例
        <div @click="()  => alert('ok')">hello world</div>
      
    • 更新时性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗

因为声明式框架在更新时比命令式框架多了“找出差异”的过程,所以声明式代码的性能不会优于命令式代码的性能。而对比命令式代码,声明式代码又具有更强的可维护性,更加的直观。所以框架要做的就是:在保持可维护性的同时让性能损失最小化

在开发过程中,原生JS操作DOM,虚拟DOM和innerHTML三者操作页面的性能都与创建页面、更新页面,页面大小、变更部分的大小有关系,选择哪种更新策略,需要结合心智负担、可维护性等因素综合考虑。

性能对比

更新策略 心智负担 可维护性 性能 适用场景
原生JS 最高 简单页面
虚拟DOM 复杂应用
innerHTML 静态内容

运行时 VS 编译时

以上文中声明式框架示例代码为例,简单描述vue.js的渲染过程:

1、通过编译器【compile】 解析模版字符串识别到需要创建一个DOM元素,设置内容为hello world,并为其绑定一个点击事件,完成后输出一个虚拟DOM【即一个描述真实DOM的js对象】

2、通过渲染函数【render】 将虚拟DOM渲染成真实的DOM树挂载到指定元素上,完成渲染

当设计一个框架的时候,有三种选择

  • 纯运行时
    • 上面提到的如果只用渲染函数,由用户直接提供虚拟DOM作为入参,就是所谓的纯运行时框架
    • 没有编译过程,也就无法添加相关的优化手段,比如tree-shaking
  • 运行时 + 编译时
    • 代码运行时由编译器将语义化代码编译成目标数据并作为渲染函数的入参,这种操作就是 运行时编译框架。它既支持运行时【即用户直接提供数据对象】,又支持编译时【即将用户语义化代码编译为目标数据】
    • 由于代码运行时才开始编译会产生一定的性能开销,因此可以在构建时就执行编译操作,以提升性能。【在 Vue 3.5.22 中,运行时编译通过 @vue/compiler-dom 实现,构建时编译通过 @vitejs/plugin-vue 实现】
  • 纯编译时
    • 如果省略上面的渲染函数,直接将用户代码通过编译器完成真实DOM的渲染,就是一个纯编译时框架。即不支持任何运行时内容。
    • 由于不需要任何运行时,而是直接将代码编译成可执行的js代码,因为性能可能会更好,但是有损灵活性。

Vue.js就是内部封装了命令式代码从而实现的面向用户的声明式框架;是运行时+编译时架构,目的在于保持灵活性的基础上尽可能的优化性能

其中组件的实现依赖于渲染器,组件中模板的编译依赖于编译器虚拟DOM作为媒介在整个渲染过程中作为组件真实DOM的载体协助实现内容渲染和更新。

虚拟DOM【vnode

虚拟DOM 是一个用来描述真实DOM的js对象。

使用虚拟DOM的好处是可以将不同类型的标签、属性及子节点抽象成一个对象,这样描述UI可以更加灵活。

// 上文中的代码可以用以下形式表示
const vnode= {
    // 标签名称
    tag'div',
    // 标签属性
    props: {
        onClick: () =>alert('ok')
    },
    // 子节点
    children'hello world'
}

vue中的h函数就是一个辅助创建虚拟DOM的工具函数

import { h } from 'vue'

export default {
    render() {
        return h('div', { onClick: () => alert('ok') }, 'hello world')
    }
}

// 等价于
export default {
    render() {
        return {
            tag: 'div',
            props: {
                onClick: () => alert('ok')
            },
            children: 'hello world'
        }
    }
}

// 等价于
<div @click="() => alert('ok')">hello world</div>

虚拟DOM的性能优势:

  • 批量更新:可以将多次DOM操作合并为一次
  • 跨平台:同一套代码可以渲染到不同平台
  • 优化策略:通过diff算法最小化DOM操作

组件

组件就是一组DOM元素的封装,它可以是一个返回虚拟DOM的函数,也可以是一个对象。组件的返回值也是虚拟DOM,它代表组件要渲染的内容。

编译器【compile】

编译器的作用是将组件模板【<template>】编译为渲染函数并添加到<script>标签块的组件对象上

// demo.vue
<template>
<div@click="handler">
        hello world
    </div>
</template>

<script>
exportdefault {
        data() { }
        methods: {
            handler: () =>alert('ok')
        }
    }
</script>

组件编译后结果:

exportdefault {
    data() {},
    methods: {
        handler: () =>alert('ok')
    },
    render() {
        return _createElementVNode('div', { onClick: handler }, 'hello world', -1/* HOISTED */)
    }
}

无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的。然后再将渲染函数返回的虚拟DOM作为渲染器的入参,进行真实DOM的渲染

Vue3的编译优化:

  • 静态提升:将静态内容提升到渲染函数外部
  • 补丁标记:为动态内容添加标记,优化diff过程【通过在虚拟DOM中添加标记实现】
  • tree-shaking:移除未使用代码

渲染器【renderer】

渲染器的作用就是递归遍历虚拟DOM对象,并调用原生DOM API来完成真实DOM的创建。

渲染器的精髓在于后续的更新,它会通过Diff算法寻找并且只更新变化内容。

大致实现思路如下:

  • 如果不是内容变更:
    • 根据vnode.tag创建对应DOM元素
    • 遍历vnode.props对象,如果keyon字符开头,说明它是一个事件,调用addEventListener绑定事件处理函数;否则作为属性添加到DOM元素上
    • 处理children,如果是字符串,就创建文本节点;如果是数组就递归调用render继续渲染,最后把创建的元素挂载到新创建的元素内
  • 否则先找出vnode对象的变更点,并且只更新变更的内容

组件渲染过程详解:

vite@vitejs/plugin-vuevue-core的关系
  • vite中使用了@vitejs/plugin-vue来处理vue组件

  • @vitejs/plugin-vue中集成了vue-core中的compiler-sfc用于解析编译Vue组件

  • compiler-sfc中调用了compiler-core中的基础逻辑进行组件的编译和渲染

当我们新建并启动vue项目后,内容是如何渲染的,又是如何实时更新的?

创建并启动一个Vue应用 

// 创建新项目
npm create vue@latest
// 进入项目后安装依赖
npm install
// 启动,实际执行的是vite命令
npm run dev

当项目运行npm run dev命令时执行内容如下:

编译阶段:

启动一个vite开发服务器,浏览器会通过这个服务器来访问此项目的网页和代码

vite是一个通用的构建工具,vite本身并不直接处理.vue文件,而是通过插件系统来处理各种类型文件,其中@vitejs/plugin-vue就是用来处理vue单文件组件的

图片

构建时阶段

Vite接收到组件请求,会执行插件【@vitejs/plugin-vue】的load钩子函数,再执行Transform钩子函数

图片

在上图钩子函数执行过程中触发了compiler-sfc相关方法的执行

图片图片

监听组件变化

@Vitejs/plugin-vue插件的核心入口文件【packages/plugin-vue/src/index.ts】中定义了Vite插件的所有钩子函数,其中handleHotUpdate钩子是Vite提供的热更新处理函数,当Vue文件发生变化时,Vite会自动调用这个钩子,此时插件会检查变化的文件是否为Vue组件,如果是则调用专门的handleHotUpdate函数packages/plugin-vue/src/handleHotUpdate.ts

图片

最终将返回

SFCTemplateCompileResults : {
    code: string, // 渲染函数代码
    ast?: RootNode, // 抽象语法树
    preamble?: string// 预处理代码
    source: string// 输入源
    tips: string[], // 提示
    errors: (string | CompilerError)[], // 错误
    map?: RawSourceMap, // 源映射
}

这个阶段会将.vue文件转换为js代码,生成的是渲染函数的字符串

运行时阶段

当浏览器加载并执行这些js代码时,就会发生真正的渲染过程

应用启动 -> createApp() -> app.mount() -> render() -> patch() -> mountElement() -> 真实DOM

图片

到此就完成了vue中基本的渲染过程。

Vue项目BMI计算器技术实现

BMI计算器工具开发技术实现

本文主要分享一下我最近开发的 BMI 计算器工具的技术实现细节。这个工具基于 Vue 3 和 Nuxt.js 构建,包含核心计算逻辑和交互式的用户界面。我们将重点关注其功能实现部分。

在线工具网址:see-tool.com/bmi-calcula…

工具截图: 在这里插入图片描述

项目结构

这个工具的实现主要分为两个部分:

  1. 逻辑层utils/bmi-calculator.js —— 负责核心的 BMI 数值计算和状态判定。
  2. 视图层pages/bmi-calculator.vue —— 负责用户交互、输入验证和结果展示。

1. 核心计算逻辑

计算逻辑封装在 calculateBmi 函数中。它接收用户的身高(cm)和体重(kg)作为输入,返回计算后的 BMI 值以及对应的身体状态类别和健康风险等级。

1.1 输入验证

在进行计算之前,我们需要确保输入的数据是有效的数值且大于 0。如果输入无效,函数会抛出一个错误,以便前端捕获处理。

  const height = Number(heightCm)
  const weight = Number(weightKg)

  if (!Number.isFinite(height) || !Number.isFinite(weight) || height <= 0 || weight <= 0) {
    throw new Error('INVALID_INPUT')
  }

1.2 BMI 计算公式

BMI 的计算公式是:体重(公斤)除以身高(米)的平方。

  const heightInMeters = height / 100
  // 体重 / (身高^2)
  const bmiRaw = weight / (heightInMeters * heightInMeters)
  // 保留一位小数
  const bmi = Number(bmiRaw.toFixed(1))

1.3 状态判定

根据计算出的 BMI 值,我们可以判定用户的身体状态。这里我们参照了常见的 BMI 标准进行分类:

  • BMI < 18.5: 偏瘦(Underweight),存在营养不良风险。
  • 18.5 ≤ BMI < 24: 正常(Normal),健康风险低。
  • 24 ≤ BMI < 28: 超重(Overweight),通过轻度风险。
  • BMI ≥ 28: 肥胖(Obese),存在较高健康风险。
  if (bmi < 18.5) {
    return { bmi, categoryKey: 'underweight', riskKey: 'malnutrition' }
  }
  if (bmi < 24) {
    return { bmi, categoryKey: 'normal', riskKey: 'low' }
  }
  if (bmi < 28) {
    return { bmi, categoryKey: 'overweight', riskKey: 'mild' }
  }
  return { bmi, categoryKey: 'obese', riskKey: 'high' }

2. Vue 页面实现

页面组件主要由输入表单和结果展示两大部分组成。使用 Vue 3 的 Composition API (<script setup>) 来管理状态和逻辑。

2.1 状态管理

我们使用 ref 来定义响应式变量,用于存储用户的输入和计算结果。

const heightCm = ref('')  // 用户输入的身高
const weightKg = ref('')  // 用户输入的体重
const result = ref(null)  // 用于存储计算结果对象,初始为 null

2.2 用户交互处理

计算操作

当用户点击“计算”按钮或在体重输入框按下回车时,会触发 handleCalculate 方法。

该方法首先调用核心计算函数 calculateBmi。如果计算成功,将结果赋值给 result,页面会自动渲染结果区域;如果捕获到错误(如输入无效),则会提示用户。

const handleCalculate = () => {
  try {
    // 调用工具函数进行计算
    const r = calculateBmi(Number(heightCm.value), Number(weightKg.value))
    result.value = r
  } catch (e) {
    // 计算失败,清空结果并提示错误
    result.value = null
    safeMessage('error', '请输入有效的身高和体重')
  }
}
加载示例

为了方便用户快速体验,我们提供了一个 loadExample 方法,一键填入预设的示例数据并触发计算。

const loadExample = () => {
  heightCm.value = '170'
  weightKg.value = '65'
  handleCalculate()
}
清空重置

clearForm 方法用于重置所有输入和结果,让用户可以重新开始。

const clearForm = () => {
  heightCm.value = ''
  weightKg.value = ''
  result.value = null
}

2.3 结果动态展示

在模板中,我们使用 v-if="result" 来控制结果卡片的显示。只有当 result 有值时,结果区域才会渲染。这种设计保证了页面初始状态的整洁。

结果卡片通过 grid 布局展示了三个关键信息:BMI 数值、身体状态和健康风险。这些信息都直接来自于 result 对象。

<div v-if="result" class="...">
  <!-- BMI 数值 -->
  <p>{{ result.bmi }}</p>
  
  <!-- 身体状态分类 -->
  <p>{{ t(`bmiCalculator.result.categoryMap.${result.categoryKey}`) }}</p>
  
  <!-- 健康风险评估 -->
  <p>{{ t(`bmiCalculator.result.riskMap.${result.riskKey}`) }}</p>
</div>

通过将计算逻辑与界面展示分离,我们保持了代码的清晰和可维护性。Vue 强大的响应式系统让我们能够轻松地通过改变数据状态来驱动界面的更新。

深入Vue 3响应式系统:为什么嵌套对象修改后界面不更新?

一句话简介:Vue 3用Proxy重构了响应式系统,但嵌套对象的"深层响应"背后藏着5个致命陷阱。本文从源码级剖析响应性丢失的根本原因,并提供5种实战解决方案。


📋 目录


1. 背景:一个让人崩溃的Bug

1.1 现场重现

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

const state = reactive({
  user: {
    name: '张三',
    address: {
      city: '北京',
      district: '朝阳区'
    }
  }
})

// ❌ 这个操作不会触发界面更新!
const updateDistrict = () => {
  state.user.address.district = '海淀区'
  console.log('已修改为:', state.user.address.district) // 显示"海淀区"
  // 但界面上还是显示"朝阳区"!
}
</script>

<template>
  <div>
    <p>当前区域: {{ state.user.address.district }}</p>
    <button @click="updateDistrict">修改区域</button>
  </div>
</template>

是不是很像你昨天遇到的Bug?

控制台显示数据已经变了,但界面纹丝不动。你开始怀疑人生:

  • "我明明用了reactive,它不是深层的吗?"
  • "难道Vue 3的响应式坏了?"
  • "是不是需要手动调用什么方法?"

1.2 为什么会这样?

Vue 3的响应式系统基于ES6的Proxy,它确实提供了"深层响应"的能力。但问题出在JavaScript的对象引用机制Vue的依赖收集时机上。

让我们从源码层面一探究竟。


2. 核心原理:Proxy的"代理陷阱"

2.1 Vue 3响应式系统架构

┌─────────────────────────────────────────────────────────┐
│                    Vue 3 响应式系统                      │
├─────────────────────────────────────────────────────────┤
│  原始对象 ──► Proxy代理 ──► 依赖收集(track) ──► 触发更新(trigger)  │
│     │           │              │               │        │
│     │           │              ▼               ▼        │
│     │           │         WeakMap存储      执行effect    │
│     │           │     {target: {key: Set<effect>}}      │
│     ▼           ▼                                       │
│  {a: 1}    Proxy{a: 1}                                  │
│              get() ──track──┐                           │
│              set() ──trigger┘                           │
└─────────────────────────────────────────────────────────┘

2.2 核心源码解析

Vue 3的reactive函数简化实现:

// 简化版源码(基于vuejs/core)
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      // 1. 收集依赖:谁在用这个属性
      track(target, key)
      const result = target[key]
      // 2. 递归代理:让嵌套对象也变成响应式
      if (isObject(result)) {
        return reactive(result)
      }
      return result
    },
    set(target, key, value) {
      const oldValue = target[key]
      target[key] = value
      // 3. 触发更新:通知所有依赖这个属性的effect
      if (hasChanged(value, oldValue)) {
        trigger(target, key)
      }
      return true
    }
  })
}

2.3 依赖收集的"懒惰性"

关键问题:Vue的依赖收集是"按需"的。

const state = reactive({
  user: {
    address: {
      district: '朝阳区'
    }
  }
})

// 场景1:模板中只访问了 state.user
// 收集的依赖:state ──► user
// 当修改 state.user.address.district 时:
// - 修改的是 address 对象,不是 user 对象
// - 没有触发 user 的 setter
// - 界面不更新!

// 场景2:模板中访问了 state.user.address.district
// 收集的依赖:state ──► user ──► address ──► district
// 这时修改 district 才会触发更新

2.4 内存结构图解

初始状态(未访问深层属性):
┌─────────────────────────────────────┐
│  targetMap (WeakMap)                │
│  ├─ state: depsMap                  │
│  │   └─ "user": Set[ComponentEffect]│
│  │   // 注意:没有"address"和"district"的依赖!  │
└─────────────────────────────────────┘

访问深层属性后:
┌─────────────────────────────────────────────────┐
│  targetMap (WeakMap)                            │
│  ├─ state: depsMap                              │
│  │   ├─ "user": Set[ComponentEffect]            │
│  ├─ state.user: depsMap (Proxy)                 │
│  │   ├─ "address": Set[ComponentEffect]         │
│  ├─ state.user.address: depsMap (Proxy)         │
│  │   ├─ "district": Set[ComponentEffect]        │
│  │   // 现在修改 district 会触发更新了!        │
└─────────────────────────────────────────────────┘

3. 5种常见陷阱与解决方案

陷阱1:直接替换嵌套对象属性

❌ 错误示例:

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

const state = reactive({
  form: {
    name: '',
    items: [
      { id: 1, value: 'A' },
      { id: 2, value: 'B' }
    ]
  }
})

// 直接修改数组中的对象属性 - 不触发更新!
const updateItem = () => {
  state.form.items[0].value = 'C'  // ❌ 界面可能不更新
}
</script>

✅ 解决方案1:使用Vue.set风格的赋值

// 方法A:使用 splice 触发数组更新
const updateItem = () => {
  const newItems = [...state.form.items]
  newItems[0] = { ...newItems[0], value: 'C' }
  state.form.items = newItems  // ✅ 触发更新
}

// 方法B:使用 Vue 提供的工具函数
import { set } from 'vue'

const updateItem = () => {
  state.form.items[0].value = 'C'
  // 强制触发更新
  state.form.items = [...state.form.items]
}

✅ 解决方案2:使用ref而非reactive

import { ref } from 'vue'

const form = ref({
  name: '',
  items: [{ id: 1, value: 'A' }]
})

const updateItem = () => {
  // 通过 .value 访问,确保触发响应
  form.value.items[0].value = 'C'
  // 需要整体赋值才会触发
  form.value.items = [...form.value.items]
}

陷阱2:解构赋值丢失响应性

❌ 错误示例:

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

// 解构会失去响应性!
const { user } = state
// user 只是一个普通对象引用,不再是 Proxy

// 修改 user 不会触发界面更新
user.name = '李四'  // ❌ 界面不更新

✅ 解决方案:

// 方法1:始终通过原始对象访问
const updateName = () => {
  state.user.name = '李四'  // ✅ 会触发更新
}

// 方法2:使用 toRefs 保持响应性
import { reactive, toRefs } from 'vue'

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

// toRefs 会将对象的每个属性转换为 ref
const { user } = toRefs(state)
// 现在 user.value 是响应式的

const updateName = () => {
  user.value.name = '李四'  // ✅ 会触发更新
}

// 方法3:在 setup 中直接使用解构(仅限<script setup>)
<script setup>
const state = reactive({ user: { name: '张三' } })
// 直接使用,不要解构
</script>

陷阱3:数组索引修改不触发更新

❌ 错误示例:

const list = reactive([1, 2, 3])

// 直接通过索引修改
list[0] = 100  // ❌ 可能不会触发更新(在某些边界情况下)

✅ 解决方案:

// 方法1:使用 splice
list.splice(0, 1, 100)  // ✅ 触发更新

// 方法2:重新赋值整个数组
list[0] = 100
list.length = list.length  // 强制触发(hack方式,不推荐)

// 方法3:使用 ref 替代
const list = ref([1, 2, 3])
list.value[0] = 100  // ✅ 总是触发更新

陷阱4:Object新增属性不响应

❌ 错误示例:

const state = reactive({
  user: { name: '张三' }
})

// 添加新属性
state.user.age = 25  // ❌ 不会触发更新(即使访问过user)

✅ 解决方案:

// 方法1:使用 Object.assign
Object.assign(state.user, { age: 25 })  // ✅ 触发更新

// 方法2:预先声明所有可能用到的属性
const state = reactive({
  user: { 
    name: '张三',
    age: undefined  // 预先声明
  }
})
state.user.age = 25  // ✅ 现在会触发更新

// 方法3:使用 ref
const user = ref({ name: '张三' })
user.value = { ...user.value, age: 25 }  // ✅ 触发更新

陷阱5:深层嵌套对象的性能陷阱

❌ 问题场景:

const bigData = reactive({
  // 1000条数据,每条都有深层嵌套
  list: Array(1000).fill(null).map((_, i) => ({
    id: i,
    info: {
      detail: {
        deep: { value: i }
      }
    }
  }))
})
// 每次访问都会递归创建 Proxy,性能爆炸!

✅ 解决方案:

import { shallowRef, triggerRef } from 'vue'

// 使用 shallowRef,只有 .value 是响应式的,内部不做深代理
const bigData = shallowRef({
  list: Array(1000).fill(null).map((_, i) => ({
    id: i,
    info: { detail: { deep: { value: i } } }
  }))
})

// 修改深层数据
const updateDeep = () => {
  bigData.value.list[0].info.detail.deep.value = 999
  // 手动触发更新
  triggerRef(bigData)  // ✅ 强制刷新界面
}

4. 深拷贝的坑:你以为的安全其实是噩梦

4.1 深拷贝为什么会破坏响应性?

import { reactive } from 'vue'
import cloneDeep from 'lodash/cloneDeep'

const state = reactive({
  user: { name: '张三', items: [{ id: 1 }] }
})

// ❌ 致命错误:深拷贝后丢失了所有响应性!
const saveData = () => {
  const dataToSave = cloneDeep(state.user)
  // dataToSave 是一个纯对象,没有任何 Proxy 包装
  // 如果你把它赋回 state,响应性就彻底断了
  state.user = dataToSave  // ❌ 现在 state.user 不再是响应式代理!
}

4.2 正确的深拷贝姿势

场景1:需要提交到后端的数据

import { toRaw } from 'vue'

const saveData = () => {
  // 使用 toRaw 获取原始对象(不会递归解包,性能更好)
  const rawData = toRaw(state.user)
  // 发送给后端
  await api.saveUser(rawData)
}

场景2:需要复制数据同时保持响应性

import { reactive } from 'vue'

const duplicateUser = () => {
  // 方法1:逐个属性复制,保持响应性
  const newUser = reactive({
    name: state.user.name,
    items: state.user.items.map(item => ({ ...item }))
  })
  
  // 方法2:使用 JSON 解析(注意:会丢失函数、Date等特殊类型)
  const newUser2 = reactive(JSON.parse(JSON.stringify(state.user)))
}

场景3:使用 Immer 进行不可变更新

import { produce } from 'immer'
import { shallowRef } from 'vue'

const state = shallowRef({
  user: { name: '张三', items: [{ id: 1, value: 'A' }] }
})

const updateItem = () => {
  // Immer 会创建新的不可变对象
  state.value = produce(state.value, draft => {
    draft.user.items[0].value = 'B'
  })
  // shallowRef 检测到 .value 变化,触发更新 ✅
}

4.3 深拷贝 vs 浅拷贝速查表

方法 是否破坏响应性 性能 适用场景
JSON.parse(JSON.stringify()) ✅ 是 简单对象,无循环引用
lodash.cloneDeep ✅ 是 复杂对象,需要完整复制
toRaw() ❌ 否(只读) 提交数据到后端
{...obj} ❌ 否(浅拷贝) 只需复制一层
structuredClone() ✅ 是 现代浏览器,支持更多类型

5. 实战案例:表格嵌套数据更新

5.1 需求描述

实现一个可编辑表格,支持:

  1. 多行数据展示
  2. 每行可以展开显示子表格
  3. 子表格数据可编辑
  4. 编辑后实时更新

5.2 完整代码实现

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

// 表格数据结构
const tableData = reactive({
  rows: [
    {
      id: 1,
      name: '产品A',
      expanded: false,
      children: [
        { id: '1-1', sku: 'SKU001', stock: 100 },
        { id: '1-2', sku: 'SKU002', stock: 200 }
      ]
    },
    {
      id: 2,
      name: '产品B',
      expanded: false,
      children: [
        { id: '2-1', sku: 'SKU003', stock: 150 }
      ]
    }
  ]
})

// ✅ 正确的更新方法:展开/收起
const toggleExpand = (row) => {
  // 直接修改会触发更新
  row.expanded = !row.expanded
}

// ✅ 正确的更新方法:修改库存
const updateStock = (row, childIndex, newStock) => {
  // 方法1:直接修改嵌套属性(如果模板中访问过这个路径)
  row.children[childIndex].stock = newStock
  
  // 方法2:如果不确定是否访问过,强制刷新
  // row.children = [...row.children]
}

// ✅ 正确的更新方法:添加子项
const addChild = (row) => {
  const newChild = {
    id: `${row.id}-${row.children.length + 1}`,
    sku: `SKU00${Date.now()}`,
    stock: 0
  }
  // 使用 push 会触发更新
  row.children.push(newChild)
  
  // 确保展开以显示新添加的行
  row.expanded = true
}

// ❌ 错误示例:直接替换整个 children 数组可能丢失响应性
const wrongUpdate = (row) => {
  // 如果 row.children 是从外部传入的非响应式数据
  row.children = row.children.map(child => ({ ...child }))  // ⚠️ 危险!
}

// ✅ 安全示例:批量更新
const batchUpdate = async (row) => {
  // 批量修改前先冻结更新
  const originalChildren = JSON.parse(JSON.stringify(row.children))
  
  // 修改数据
  originalChildren.forEach(child => {
    child.stock += 10
  })
  
  // 一次性赋值,触发单次更新
  row.children = originalChildren
  
  // 等待 DOM 更新
  await nextTick()
  console.log('批量更新完成')
}
</script>

<template>
  <div class="table-container">
    <table>
      <thead>
        <tr>
          <th>展开</th>
          <th>ID</th>
          <th>名称</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody>
        <template v-for="row in tableData.rows" :key="row.id">
          <!-- 主行 -->
          <tr class="main-row">
            <td>
              <button @click="toggleExpand(row)">
                {{ row.expanded ? '▼' : '▶' }}
              </button>
            </td>
            <td>{{ row.id }}</td>
            <td>{{ row.name }}</td>
            <td>
              <button @click="addChild(row)">添加子项</button>
              <button @click="batchUpdate(row)">批量+10</button>
            </td>
          </tr>
          
          <!-- 子表格 -->
          <tr v-if="row.expanded" class="child-row">
            <td colspan="4">
              <table class="child-table">
                <thead>
                  <tr>
                    <th>SKU</th>
                    <th>库存</th>
                  </tr>
                </thead>
                <tbody>
                  <tr v-for="(child, index) in row.children" :key="child.id">
                    <td>{{ child.sku }}</td>
                    <td>
                      <input 
                        type="number" 
                        v-model="child.stock"
                        @change="updateStock(row, index, child.stock)"
                      />
                    </td>
                  </tr>
                </tbody>
              </table>
            </td>
          </tr>
        </template>
      </tbody>
    </table>
  </div>
</template>

<style scoped>
.table-container {
  padding: 20px;
}
table {
  width: 100%;
  border-collapse: collapse;
}
th, td {
  border: 1px solid #ddd;
  padding: 8px;
  text-align: left;
}
.main-row {
  background: #f5f5f5;
}
.child-row {
  background: #fff;
}
.child-table {
  margin: 10px;
  width: calc(100% - 20px);
}
input {
  width: 80px;
  padding: 4px;
}
</style>

5.3 关键点总结

  1. 模板访问路径很重要:确保模板中访问了你要修改的完整路径
  2. 数组方法优先使用pushsplice 等方法会触发更新
  3. 批量更新优化:多次修改后一次性赋值,减少重渲染次数
  4. nextTick 的时机:需要在 DOM 更新后执行操作时记得使用

6. 性能优化:大规模数据下的最佳实践

6.1 虚拟滚动 + shallowRef

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

// 超大数据列表(10万条)
const hugeList = shallowRef([
  // 假设这里有10万条嵌套数据
])

// 只显示可视区域的数据
const visibleData = computed(() => {
  const start = scrollTop.value // 当前滚动位置
  const end = start + visibleCount.value // 可视数量
  return hugeList.value.slice(start, end)
})

// 修改数据时手动触发
const updateItem = (index, newData) => {
  hugeList.value[index] = newData
  triggerRef(hugeList) // 手动触发更新
}

6.2 分页加载与局部响应

import { reactive, ref } from 'vue'

const state = reactive({
  // 只有当前页的数据是响应式的
  currentPageData: [],
  // 总数据只存原始数据,不做响应式处理
  allData: []
})

// 切换页面时更新响应式数据
const changePage = (page) => {
  const start = (page - 1) * pageSize
  const end = start + pageSize
  // 只让当前页数据成为响应式
  state.currentPageData = state.allData.slice(start, end)
}

6.3 使用 Map/Set 替代对象数组

import { reactive } from 'vue'

// ❌ 低效:大数组查找
const list = reactive([
  { id: 1, data: {} },
  { id: 2, data: {} },
  // ... 10000条
])
// 查找需要 O(n)
const item = list.find(i => i.id === targetId)

// ✅ 高效:使用 Map
const dataMap = reactive(new Map())
dataMap.set(1, { data: {} })
dataMap.set(2, { data: {} })
// 查找只需 O(1)
const item = dataMap.get(targetId)

7. 总结与避坑清单

7.1 核心要点回顾

┌─────────────────────────────────────────────────────────────┐
│                   Vue 3 嵌套数据更新避坑指南                  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 访问路径原则                                            │
│     └── 模板中必须访问到你要修改的最深层属性                   │
│                                                             │
│  2. 赋值触发原则                                            │
│     └── 直接修改对象属性可能不触发,考虑整体替换              │
│                                                             │
│  3. 解构危险                                                │
│     └── 解构 reactive 对象会失去响应性,使用 toRefs          │
│                                                             │
│  4. 深拷贝陷阱                                              │
│     └── cloneDeep 会破坏响应性,使用 toRaw 或浅拷贝          │
│                                                             │
│  5. 性能优化                                                │
│     └── 大数据用 shallowRef + triggerRef 手动控制            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

7.2 快速决策流程图

遇到嵌套数据不更新?
    │
    ├─ 是否在模板中访问了完整路径?
    │   ├─ 否 → 补充访问路径:{{ obj.level1.level2 }}
    │   └─ 是 → 继续
    │
    ├─ 是否使用了深拷贝(cloneDeep)?
    │   ├─ 是 → 换成 toRaw() 或浅拷贝
    │   └─ 否 → 继续
    │
    ├─ 是否解构了 reactive 对象?
    │   ├─ 是 → 使用 toRefs() 或避免解构
    │   └─ 否 → 继续
    │
    ├─ 数据量是否很大(>1000条)?
    │   ├─ 是 → 使用 shallowRef + triggerRef
    │   └─ 否 → 继续
    │
    └─ 尝试强制刷新:
        ├─ 数组:arr = [...arr]
        ├─ 对象:obj = { ...obj }
        └─ 或使用 nextTick() 延迟更新

7.3 推荐工具函数

// utils/reactiveHelper.js

import { reactive, toRaw, isProxy } from 'vue'

/**
 * 安全地更新嵌套对象属性
 */
export function safeUpdate(obj, path, value) {
  const keys = path.split('.')
  let current = obj
  
  for (let i = 0; i < keys.length - 1; i++) {
    current = current[keys[i]]
  }
  
  current[keys[keys.length - 1]] = value
  
  // 如果是 reactive 对象,触发更新
  if (isProxy(obj)) {
    // 强制刷新(hack 方式,慎用)
    Object.assign(obj, obj)
  }
}

/**
 * 深度克隆但保持响应性(适用于简单对象)
 */
export function cloneReactive(obj) {
  const raw = toRaw(obj)
  return reactive(JSON.parse(JSON.stringify(raw)))
}

/**
 * 批量更新数组(减少重渲染)
 */
export function batchUpdateArray(arr, updates) {
  // updates: [{ index: 0, value: newValue }, ...]
  const newArr = [...arr]
  updates.forEach(({ index, value }) => {
    newArr[index] = value
  })
  return newArr
}

7.4 最后的话

Vue 3的响应式系统基于Proxy确实是巨大的进步,但它不是银弹。理解依赖收集的惰性Proxy的代理边界,是避免嵌套数据更新问题的关键。

记住:响应式不是魔法,是精确追踪。当你明白Vue在什么时机、追踪哪些依赖,你就能游刃有余地处理任何复杂的数据结构。


参考链接

  1. Vue 3 响应式原理官方文档 - 验证状态: ✅
  2. Vue 3 Reactivity API 高级用法 - 验证状态: ✅
  3. GitHub Issue #1387 - 嵌套属性更新问题 - 验证状态: ✅
  4. Proxy MDN 文档 - 验证状态: ✅
  5. Immer 不可变数据更新库 - 验证状态: ✅

如果本文对你有帮助,欢迎点赞收藏!你在使用 Vue 3 响应式时还遇到过哪些坑?欢迎在评论区分享。

高德地图「点标记+点聚合」加载慢、卡顿问题 解决方案

coffee.joiepink.space/

Coffee网页的设计灵感来自于一个普通的在星巴克喝咖啡的下午,突发奇想能不能把全国的星巴克门店都整合到一起,用地图可视化的形式展示门店分布密度,为咖啡爱好者提供便捷的门店查询和导航服务。

于是便诞生了。

技术栈:Vue Amap UnoCSS Icônes Vant ESLint Vite

coffee-performance-01.png

痛点:本项目有8000+的数据量,需要在高德地图上点标记每家星巴克门店且支持交互,由于数据量过大,首次进入页面加载速度很慢,且把8000+点标记显示在地图上,点标记的交互动作会崩盘,地图操作响应速度也会变慢、卡顿,非常影响用户的使用体验

基于此,我从以下几个方面对项目进行了性能优化

  1. Amap SDK按需、动态加载
  2. 用 shallowRef 存地图相关实例
  3. 点聚合 + 只渲染视野内点位
  4. 视口变化防抖 + 只绑一次
  5. 首屏后再拉数据
  6. 主题切换与地图样式

1. Amap SDK按需、动态加载

在地图页面的js中,我并不在js顶部写import AMapLoader from '@amap/amap-jsapi-loader'

这种使用方法有两个弊端:

第一方面,在{Vite}打包的时候,这个依赖会被打包进入首屏就要加载的bundle(主chunk或和主入口一起被加载的chunk),用户第一次打开页面的时候,浏览器就会一起下载这份包含高德的JS,导致首屏体积变大,加载速度变慢。

第二方面,这种方式在模块被Node执行的时候们就会运行,于是会加载@amap/amap-jsapi-loader及其内部依赖, 而{Amap}内部SDK/loader会用到window,但是Node里面是没有window的,所以会导致报错(例如 Reference Error: window is not defined)。

为了避免以上两种问题,我在初始化{Amap}的函数里写const {default: AMapLoader} = await import('@amp/amp-jsapi-loader'),这个函数只在onMounted生命周期中调用,也就是说只在浏览器里、页面挂载之后才会执行

在{Vite}打包的时候,@amap/amap-jsapi-loader会被打包成单独的chunk,只有执行到const {default: AMapLoader} = await import('@amp/amp-jsapi-loader')的时候才会加载这段JS,首屏主bundle里并没有{Amap}相关代码,所以首包更小、首屏更快。

SSG时Node不会执行onMounred钩子,所以不会执行这段import,自然也就不会在Node里加载高德,不会碰到window,避免了报错。

async function initMap() {
  const { default: AMapLoader } = await import('@amap/amap-jsapi-loader') // [!code hl]
  const amapKey = import.meta.env.VITE_AMAP_KEY
  if (!amapKey)
    return Promise.reject(new Error('Missing VITE_AMAP_KEY'))
  window._AMapSecurityConfig = { securityJsCode: amapKey }
  return AMapLoader.load({
    key: amapKey,
    version: '2.0',
    plugins: ['AMap.Scale', 'AMap.MarkerCluster', 'AMap.Geolocation'], // [!code hl]
  })
}

initMap函数中,我使用{Amap}2.0的按插件加载特性,通过AMapLoader.load({plugins: [...]})按需加载需要的插件,这种方式在项目中精准引入需要使用的插件,使得项目请求更少、解析更少、地图初始化更轻,从而加快了加载速度、减小了打包的包体积。

2. 用 shallowRef 存地图相关实例

const map = shallowRef(null)
const currentLocationMarker = shallowRef(null)
const geolocation = shallowRef(null)
const Amap = shallowRef(null)

/** 根据 isDark 设置地图底图样式;返回 Promise,地图样式切换完成后再 resolve */
function applyMapStyle() {
    if (!map.value) return Promise.resolve()
  const style = i
   sDark.value ? 'amap://styles/dark' : 'amap://styles/normal'
  return new Promise((resolve) => {
      nextTick(() => {
        map.value?.setMapStyle(style)
      setTimeout(resolve, 1800)
    })
})
}

/** 点击切换主题 */
function onThemeToggle() {
    themeChanging.value = true
  nextTick(() => {
      toggleDark()

/** 监听 isDark,切完样式再关 loading */
watch(isDark, () => {
    const p = applyMapStyle()
  if (p) {
      p.finally(() => {
        themeChanging.value = false

    se {
  }
    themeChanging.value = false
}

先来说一下{Vue}中refshallowRef的区别

ref: {Vue}会对你塞进去的整个对象做深度响应式代理——递归的把对象里每一层属性都报称getter/setter(Proxy), 这样任意一层属性变了都会触发更新

shallowRef:只对[.value这一层]做响应式。当把一个对象赋值给shallowRef.value的时候,{Vue}不会去递归代理这个对象内部,内部属性变了{Vue}并不知道,但只有把整个对象换掉(重新赋值.value)时候才会触发更新

如果使用ref来存储{Amap}实例,会出现因深度代理造成的postMessage克隆报错,例如:DOMException: Failed to execute 'postMessage' on 'Worker': AMap.Map#xxx could not be cloned.

{Amap}的实例如Map Marker Geolocation MarkerCluster等内部会用到postMessage例如和地图iframe/worker通信

浏览器在发postMessage的时候,会对要传的数据做结构化克隆 structured clone,把对象序列号之后再在另一边反序列化,而Proxy对象不支持被克隆,所以会报错

能被结构化克隆的:普通对象、数组、部分简单类型

不能被结构化克隆的:函数、DOM节点、Proxy对象(Vue的响应式对象就是Proxy)

如果使用shallowRef存储的话,赋值给shallowRef.value的是{Amap}原始的实例对象,{Vue}不会去递归代理它里面的属性,也就不会报错。而ref存储会递归遍历、创建大量的Proxy,但其实并不需要在地图内部某个坐标变了就触发Vue更新,我们只需要在换地图、换marker、换聚合的这种整实例替换的时候更新即可。所以shallowRef的时候内存和CPU开销都更小,从而最性能有利。

3. 点聚合 + 只渲染视野内点位

为了解决数据量过大导致DOM数量巨大(每个门店创建一个marker标记)导致卡顿崩盘的问题,我使用{Amap}的点聚合 MarkerCluster将距离近的一批点在逻辑上归为一组,地图上只画一个聚合点,用户放大地图时,聚合会拆开变成更小的簇或者单点,缩小地图的时候,会合并成更大的簇。

即使使用点聚合 MarkerCluster,如果把全国所有门店(8000+)的数据量一次性都塞给点聚合 MarkerCluster,聚合算法要对这所有点都做距离计算、分簇、计算量十分之庞大,而绝大部分点并不在当前用户所见的屏幕内,用户根本看不到,却还是在耗费后台进行参与计算和内部管理,所以,更合理的做法是只把当前视野 current viewport内的点标记交给点聚合 MarkerCluster进行计算,而视野外的点不参与计算和渲染,当用户拖动地图画布或者放大缩小当前视野的时候,再进行计算参与。

具体做法如下:

function updateClusterByViewport(AmapInstance) {
    if (!map.value || !pointList.value?.length) return
  // 返回当前地图可视区域的地理范围(一个矩形,有西南角、东北角经纬度)。

  const b = map.value.getBounds()
  if (!b) return
  // 过滤当前
   视野内的点数据
  const inBounds = pointList.value.filter((point) => {
      const ll = point.lnglat
    const lng = Array.isArray(ll) ? ll[0] : (ll?.lng ?? ll?.longitude)
    const lat = Array.isArray(ll) ? ll[1] : (ll?.lat ?? ll?.latitude)
    // 判断点是否在当前视野矩形内
    return lng != null && lat != null && b.contains(new AmapInstance.LngLat(lng, lat))
   }
  // 把「视野内点数」存起来给界面用
  pointsInBounds.value = inBounds

    // 销毁旧聚合并只拿视野内的点重建聚合
  if (clusterRef.value) {
      clusterRef.value.setMap(null)
    clusterRef.value = null
   }
  if (!inBounds.length) return
  const myList = inBoun
   ds.map((point) => ({
      lnglat: Array.isArray(pont.lnlat)
        ? point.lnglat
      : [point.lnglat?.lng ?? point.lnglat?.longitude, point.lnglat?.lat ?? point.lnglat?.latitude],
   id:point.id,
  })
  const gridSize = 60
  const cluster = new AmapInstance.MarkerCluster(map.value, myList, {
      gridSize,
    renderClusterMarker: createRenderClusterMarker(AmapInstance, myList.length),
    renderMarker: createRenderStoreMarker(AmapInstance),
  })
  setupClusterClickZoom(cluster)
  clusterRef.value = cluster
}

4. 视口变化防抖 + 只绑一次

当用户拖拽、缩放地图的时候,地图会连续触发很多次moveend/zoomend的事件,如果每次触发都执行上文的updateClusterByViewport方法,计算执行过于频繁,容易造成页面卡顿、浪费CPU,因此为这些操作都加上防抖

const onViewportChange = useDebounceFn(() => updateClusterByViewport(Amap.value), 150)
map.value.on('moveend', onViewportChange)
map.value.on('zoomend', onViewportChange)

5. 首屏后再拉数据

首屏加载的时候,应该把注意力放在地图容器快速渲染上面,从而给用户一个比较好的使用体验。而加载数据(loadAndRenderStores)会执行请求数据、处理数据、渲染视野内点聚合这一系列操作,逻辑较重,因此如果在地图还没准备好、或者首屏还在渲染的时候同步做这些事情,就会占用主线程,从而拖慢首屏DOM的渲染、拖慢地图SDK的首次绘制,所以把目标变成:先让首屏和地图第一次渲染完成,再在浏览器空闲的时候去拉取数据、计算聚合。

map.value.on('complete', () => {
  const run = () => loadAndRenderStores(AmapInstance)
  if (typeof requestIdleCallback !== 'undefined') {
    requestIdleCallback(run, { timeout: 500 })
  }
  else {
    setTimeout(run, 0)
  }
  mapReady.value = true

  tryEndFirstScreenLoading()
})

6. 主题切换与地图样式

项目中 设置了DarkLight两种主题模式,而在切换的时候,地图样式切换是异步的,比导航条样式切换慢,这就会导致出现导航条已经变化主题,但地图主题还没更新,中间有一小段时间两者颜色不一致,甚至会闪动一下,带给用户不好的体验效果

为了解决这个问题,我在切换主题的[中间态]中,将页面用全屏loading遮罩层罩住,等地图样式基本切换完毕再隐藏,避免了中间态的闪烁问题

小结

至此,{Amap}相关的性能优化结束,首屏加载从原先的8,304ms优化到了4,181ms加载时间减少了4,123ms,性能提升了约49.65%,加载速度快了一倍

优化前:

coffee-performance-02.webp

优化后:

coffee-performance-03.png

虚拟列表:从定高到动态高度的 Vue 3 & React 满分实现

前言

在处理海量数据渲染(如万级甚至十万级列表)时,直接操作 DOM 会导致严重的页面卡顿甚至崩溃。虚拟列表(Virtual List) 作为前端性能优化的“核武器”,通过“只渲染可视区”的策略,能将渲染性能提升数个量级。本文将带你从零实现一个支持动态高度的通用虚拟列表。

定高虚拟列表滚动.gif

一、 核心原理解析

虚拟列表本质上是一个“障眼法”,其结构通常分为三层:

  1. 外层容器(Container) :固定高度,设置 overflow: auto,负责监听滚动事件。
  2. 占位背景(Placeholder) :高度等于“总数据量 × 列表项高度”,用于撑开滚动条,模拟真实滚动的视觉效果。
  3. 渲染内容区(Content Area) :绝对定位,根据滚动距离动态计算起始索引,并通过 translateY 偏移到当前可视区域。

image.png


二、 定高虚拟列表

1. 设计思路

  • 可视项数计算Math.ceil(容器高度 / 固定高度) ± 缓冲区 (BUFFER)
  • 起始索引Math.floor(滚动距离 / 固定高度)
  • 偏移量起始索引 * 固定高度

2. Vue 3 + TailwindCSS实现

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:用于撑开滚动条,高度 = 总数据量 * 每项高度 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表:通过 transform 定位到滚动位置 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <div
            v-for="item in visibleList"
            :key="item.id"
            class="py-2 px-4 border-b border-gray-200"
            :class="{
              'bg-pink-200 h-[100px]': item.id % 2 !== 0,
              'bg-green-200 h-[100px]': item.id % 2 === 0,
            }"
          >
            {{ item.name }}
          </div>
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

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

const router = useRouter();

const ITEM_HEIGHT = 100; // 列表项固定高度(与样式中的 h-[100px] 一致)
const BUFFER = 5; // 缓冲区数量,避免滚动时出现空白

const virtualListRef = ref<HTMLDivElement | null>(null);

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动容器的滚动距离

// 总列表高度(撑开滚动条用)
const totalHeight = computed(() => ListData.value.length * ITEM_HEIGHT);

// 可视区域高度(滚动容器的高度)
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || 0;
});

// 可视区域可显示的列表项数量(向上取整 + 缓冲区)
const visibleCount = computed(() => {
  return Math.ceil(viewportHeight.value / ITEM_HEIGHT) + BUFFER;
});

// 当前显示的起始索引
const startIndex = computed(() => {
  // 滚动距离 / 每项高度 = 跳过的项数(向下取整)
  const index = Math.floor(scrollTop.value / ITEM_HEIGHT);
  // 防止索引为负数
  return Math.max(0, index);
});

// 当前显示的结束索引
const endIndex = computed(() => {
  const end = startIndex.value + visibleCount.value;
  // 防止超出总数据长度
  return Math.min(end, ListData.value.length);
});

// 可视区域需要渲染的列表数据
const visibleList = computed(() => {
  return ListData.value.slice(startIndex.value, endIndex.value);
});

// 可视区域的偏移量(让列表项定位到正确位置)
const offsetY = computed(() => {
  return startIndex.value * ITEM_HEIGHT;
});

// 处理滚动事件
const handleScroll = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

// 返回首页
const goBack = () => {
  router.push('/home');
};

// 初始化
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
});
</script>

3. 实现效果图

定高虚拟列表滚动.gif


三、 进阶:不定高(动态高度)虚拟列表

在实际业务(如社交动态、聊天记录)中,每个 Item 的高度往往是不固定的。

1. 核心改进思路

  • 高度映射表(Map) :记录每一个 Item 渲染后的真实高度。
  • 累计高度数组(Cumulative Heights) :存储每一项相对于顶部的偏移位置。
  • ResizeObserver:利用该 API 监听子组件高度变化,实时更新映射表,解决图片加载或文本折行导致的位移。

2. Vue 3 + tailwindCSS 实现(子组件抽离)

子组件: 负责上报真实高度:

<template>
  <div
    ref="itemRef"
    class="py-2 px-4 border-b border-gray-200"
    :class="{
      'bg-pink-200': item.id % 2 !== 0,
      'bg-green-200': item.id % 2 === 0,
    }"
    :style="{ height: item.id % 2 === 0 ? '150px' : '100px' }"
  >
    {{ item.name }}
  </div>
</template>

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

// 定义props:接收父组件传递的item数据
const props = defineProps<{
  item: {
    id: number;
    name: string;
  };
}>();

// 定义emit:向父组件传递高度更新事件
const emit = defineEmits<{
  (e: 'update-height', id: number, height: number): void;
}>();

const itemRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;

// 计算并发送当前组件的高度
const sendItemHeight = () => {
  if (!itemRef.value) return;
  const realHeight = itemRef.value.offsetHeight;
  emit('update-height', props.item.id, realHeight);
};

// 监听组件挂载:首次发送高度 + 监听高度变化
onMounted(() => {
  // 首次渲染完成后发送高度
  nextTick(() => {
    sendItemHeight();
  });

  // 监听元素高度变化(适配动态内容导致的高度变化)
  if (window.ResizeObserver) {
    resizeObserver = new ResizeObserver(() => {
      sendItemHeight();
    });
    if (itemRef.value) {
      resizeObserver.observe(itemRef.value);
    }
  }
});

// 组件更新后重新发送高度(比如内容变化)
onUpdated(() => {
  nextTick(() => {
    sendItemHeight();
  });
});

// 组件卸载:清理监听
onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});

// 监听item变化:如果item替换,重新计算高度
watch(
  () => props.item.id,
  () => {
    nextTick(() => {
      sendItemHeight();
    });
  }
);
</script>

父组件:核心逻辑

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:撑开滚动条 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <!-- 渲染子组件,监听高度更新事件 -->
          <VirtualListItem
            v-for="item in visibleList"
            :key="item.id"
            :item="item"
            @update-height="handleItemHeightUpdate"
          />
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import VirtualListItem from './listItem.vue'; // 引入子组件

const router = useRouter();

const MIN_ITEM_HEIGHT = 100; // 子项预设的最小高度
const BUFFER = 5; //上下缓冲区数目
const virtualListRef = ref<HTMLDivElement | null>(null); // 滚动容器引用

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动距离
const itemHeights = ref<Map<number, number>>(new Map()); // 子组件高度映射表
const cumulativeHeights = ref<number[]>([0]); // 累计高度数组
const scrollTimer = ref<number | null>(null); // 滚动节流定时器
const isUpdatingCumulative = ref(false); // 累计高度更新防抖

// 初始化位置数据
const initPositionData = () => {
  // 初始化高度映射表(默认最小高度)
  const heightMap = new Map<number, number>();
  ListData.value.forEach((item) => {
    heightMap.set(item.id, MIN_ITEM_HEIGHT);
  });
  // 初始化累计高度
  updateCumulativeHeights();
};

// 更新累计高度(核心)
const updateCumulativeHeights = () => {
  if (isUpdatingCumulative.value) return;
  isUpdatingCumulative.value = true;

  const itemCount = ListData.value.length;
  const cumulative = [0];
  let sum = 0;

  for (let i = 0; i < itemCount; i++) {
    const itemId = ListData.value[i].id;
    sum += itemHeights.value.get(itemId) || MIN_ITEM_HEIGHT;
    cumulative.push(sum);
  }

  cumulativeHeights.value = cumulative;
  isUpdatingCumulative.value = false;
};

// 处理子组件的高度更新事件
const handleItemHeightUpdate = (id: number, height: number) => {
  // 高度未变化则跳过
  if (itemHeights.value.get(id) === height) return;

  // 更新高度映射表
  itemHeights.value.set(id, height);

  // 异步更新累计高度(避免同步更新导致的性能问题)
  nextTick(() => {
    updateCumulativeHeights();
  });
};

// 总高度,根据统计高度数组最后一个值计算得出
const totalHeight = computed(() => {
  return cumulativeHeights.value[cumulativeHeights.value.length - 1] || 0;
});

// 列表可视区域高度
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || MIN_ITEM_HEIGHT * 5;
});

// 计算起始索引
const startIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  if (totalItemCount === 0) return 0;
  if (scrollTop.value <= 0) return 0;

  let baseStartIndex = 0;
  // 反向遍历找起始索引
  for (let i = cumulativeHeights.value.length - 1; i >= 0; i--) {
    if (cumulativeHeights.value[i] <= scrollTop.value) {
      baseStartIndex = i;
      break;
    }
  }
  const finalIndex = Math.max(0, baseStartIndex - BUFFER); // 确保不小于0
  return Math.min(finalIndex, totalItemCount - 1);
});

// 计算结束索引
const endIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  const viewportHeightVal = viewportHeight.value;
  if (totalItemCount === 0) return 0;

  const targetScrollBottom = scrollTop.value + viewportHeightVal; // 目标滚动到底部位置
  let baseEndIndex = totalItemCount - 1;
  for (let i = 0; i < cumulativeHeights.value.length; i++) {
    if (cumulativeHeights.value[i] > targetScrollBottom) {
      baseEndIndex = i - 1;
      break;
    }
  }
  const finalEndIndex = Math.min(baseEndIndex + BUFFER, totalItemCount - 1); // 确保不大于总项数-1
  return finalEndIndex;
});

// 可见列表
const visibleList = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return start <= end ? ListData.value.slice(start, end + 1) : [];
});

const offsetY = computed(() => {
  return cumulativeHeights.value[startIndex.value] || 0;
});

// 滚动节流处理
const handleScroll = () => {
  if (!virtualListRef.value) return;

  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  scrollTimer.value = window.setTimeout(() => {
    scrollTop.value = virtualListRef.value!.scrollTop;
  }, 20);
};

const handleResize = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

const goBack = () => {
  router.push('/home');
};

// 生命周期
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
  initPositionData();
  window.addEventListener('resize', handleResize); // 监听窗口大小变化
});

onUnmounted(() => {
  window.removeEventListener('resize', handleResize);
  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  isUpdatingCumulative.value = false;
  itemHeights.value.clear();
});
</script>

3. React + tailwindCSS 实现(子组件抽离)

子组件:

import React, { useEffect, useRef, useState, useCallback } from 'react';

interface VirtualListItemProps {
  item: {
    id: number;
    name: string;
  };
  onUpdateHeight: (id: number, height: number) => void; // 替代 Vue 的 emit
}

const VirtualListItem: React.FC<VirtualListItemProps> = ({
  item,
  onUpdateHeight,
}) => {
  const itemRef = useRef<HTMLDivElement>(null);
  // 存储 ResizeObserver 实例(避免重复创建)
  const resizeObserverRef = useRef<ResizeObserver | null>(null);

  // 计算并上报高度
  const sendItemHeight = useCallback(() => {
    if (!itemRef.current) return;
    const realHeight = itemRef.current.offsetHeight;
    onUpdateHeight(item.id, realHeight);
  }, [item.id, onUpdateHeight]);

  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);

    // 初始化 ResizeObserver 监听高度变化
    if (window.ResizeObserver) {
      resizeObserverRef.current = new ResizeObserver(() => {
        sendItemHeight();
      });
      if (itemRef.current) {
        resizeObserverRef.current.observe(itemRef.current);
      }
    }

    // 清理定时器(对应 Vue 的 onUnmounted 部分)
    return () => {
      clearTimeout(timer);
      if (resizeObserverRef.current) {
        resizeObserverRef.current.disconnect();
        resizeObserverRef.current = null;
      }
    };
  }, [sendItemHeight]); // 仅首次挂载执行

  //监听 item 变化重新计算高度
  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);
    return () => clearTimeout(timer);
  }, [item.id, sendItemHeight]); // item.id 变化时执行

  const itemClass = `py-2 px-4 border-b border-gray-200 ${
    item.id % 2 !== 0 ? 'bg-pink-200' : 'bg-green-200'
  }`;

  const itemStyle: React.CSSProperties = {
    height: item.id % 2 === 0 ? '150px' : '100px',
  };

  return (
    <div ref={itemRef} className={itemClass} style={itemStyle}>
      {item.name}
    </div>
  );
};

export default VirtualListItem;


父组件:

import React, {
  useEffect,
  useRef,
  useState,
  useCallback,
  useMemo,
} from 'react';
import VirtualListItem from './listItem';

const VirtualList: React.FC = () => {
  const MIN_ITEM_HEIGHT = 100; // 最小项高度
  const BUFFER = 5; // 缓冲区项数

  const virtualListRef = useRef<HTMLDivElement>(null); // 虚拟列表容器引用

  const [listData, setListData] = useState<Array<{ id: number; name: string }>>(
    []
  ); // 列表数据
  const [scrollTop, setScrollTop] = useState(0); // 滚动位置
  const [itemHeights, setItemHeights] = useState<Map<number, number>>(
    new Map()
  ); // 高度映射表(Map 结构)
  const [cumulativeHeights, setCumulativeHeights] = useState<number[]>([0]); // 累计高度数组
  const scrollTimerRef = useRef<number | null>(null); // 滚动节流定时器

  // 初始化模拟数据
  const initData = () => {
    const mockData = Array.from({ length: 1000 }, (_, index) => ({
      id: index,
      name: `Item ${index}`,
    }));
    setListData(mockData);
    // 初始化高度映射表(默认最小高度)
    const initHeightMap = new Map<number, number>();
    mockData.forEach((item) => {
      initHeightMap.set(item.id, MIN_ITEM_HEIGHT);
    });
    setItemHeights(initHeightMap);
    // 初始化累计高度
    updateCumulativeHeights(initHeightMap, mockData);
  };

  useEffect(() => {
    initData();
    // 监听窗口大小变化
    const handleResize = () => {
      if (virtualListRef.current) {
        setScrollTop(virtualListRef.current.scrollTop);
      }
    };
    window.addEventListener('resize', handleResize);

    // 清理监听
    return () => {
      window.removeEventListener('resize', handleResize);
      if (scrollTimerRef.current) {
        clearTimeout(scrollTimerRef.current);
      }
      itemHeights.clear(); // 清空 Map 释放内存
    };
  }, []);

  // 更新累计高度(核心函数)
  const updateCumulativeHeights = useCallback(
    (heightMap: Map<number, number>, data: typeof listData) => {
      const cumulative = [0];
      let sum = 0;
      for (let i = 0; i < data.length; i++) {
        const itemId = data[i].id;
        sum += heightMap.get(itemId) || MIN_ITEM_HEIGHT;
        cumulative.push(sum);
      }
      setCumulativeHeights(cumulative);
    },
    [MIN_ITEM_HEIGHT]
  );

  // 处理子组件的高度更新事件(对应 Vue 的 handleItemHeightUpdate)
  const handleItemHeightUpdate = useCallback(
    (id: number, height: number) => {
      // 高度未变化则跳过
      if (itemHeights.get(id) === height) return;

      // 更新高度映射表
      const newHeightMap = new Map(itemHeights);
      newHeightMap.set(id, height);
      setItemHeights(newHeightMap);

      // 异步更新累计高度
      setTimeout(() => {
        updateCumulativeHeights(newHeightMap, listData);
      }, 0);
    },
    [itemHeights, listData, updateCumulativeHeights]
  );

  // 滚动节流处理
  const handleScroll = useCallback(() => {
    if (!virtualListRef.current) return;

    // 节流:20ms 内只更新一次 scrollTop
    if (scrollTimerRef.current) {
      clearTimeout(scrollTimerRef.current);
    }
    scrollTimerRef.current = setTimeout(() => {
      setScrollTop(virtualListRef.current!.scrollTop);
    }, 20);
  }, []);

  // 可视区域高度
  const viewportHeight = useMemo(() => {
    return virtualListRef.current?.clientHeight || MIN_ITEM_HEIGHT * 5;
  }, []);

  //  总列表高度
  const totalHeight = useMemo(() => {
    return cumulativeHeights[cumulativeHeights.length - 1] || 0;
  }, [cumulativeHeights]);

  // 起始索引
  const startIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;
    if (scrollTop <= 0) return 0;

    // 反向遍历找起始索引
    let baseStartIndex = 0;
    for (let i = cumulativeHeights.length - 1; i >= 0; i--) {
      if (cumulativeHeights[i] <= scrollTop) {
        baseStartIndex = i;
        break;
      }
    }

    const finalIndex = Math.max(0, baseStartIndex - BUFFER);
    return Math.min(finalIndex, totalItemCount - 1);
  }, [
    scrollTop,
    viewportHeight,
    totalHeight,
    cumulativeHeights,
    listData.length,
  ]);

  // 结束索引
  const endIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;

    const targetScrollBottom = scrollTop + viewportHeight;
    let baseEndIndex = totalItemCount - 1;

    for (let i = 0; i < cumulativeHeights.length; i++) {
      if (cumulativeHeights[i] > targetScrollBottom) {
        baseEndIndex = i - 1;
        break;
      }
    }

    let finalEndIndex = baseEndIndex + BUFFER;
    finalEndIndex = Math.min(finalEndIndex, totalItemCount - 1);
    return finalEndIndex;
  }, [scrollTop, viewportHeight, cumulativeHeights, listData.length]);

  // 可视区列表
  const visibleList = useMemo(() => {
    return startIndex <= endIndex
      ? listData.slice(startIndex, endIndex + 1)
      : [];
  }, [startIndex, endIndex, listData]);

  // 偏移量
  const offsetY = useMemo(() => {
    return cumulativeHeights[startIndex] || 0;
  }, [startIndex, cumulativeHeights]);

  return (
    <div className="h-full bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5">
      <div className="bg-white mt-10 h-[calc(100vh-200px)] rounded-xl">
        {/* 滚动容器 */}
        <div
          ref={virtualListRef}
          className="h-full overflow-auto relative"
          onScroll={handleScroll}
        >
          {/* 占位容器:撑开滚动条 */}
          <div style={{ height: `${totalHeight}px` }}></div>

          {/* 可视区域列表:transform 偏移 */}
          <div
            className="absolute top-0 left-0 right-0"
            style={{ transform: `translateY(${offsetY}px)` }}
          >
            {visibleList.map((item) => (
              <VirtualListItem
                key={item.id}
                item={item}
                onUpdateHeight={handleItemHeightUpdate}
              />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

4. 实现效果图

动高虚拟列表滚动.gif


四、 总结与避坑指南

1. 为什么需要缓冲区(BUFFER)?

如果只渲染可见部分,用户快速滚动时,异步渲染可能会导致瞬间的“白屏”。设置上下缓冲区可以预加载部分 DOM,让滑动更顺滑。

2. 性能进一步优化

  • 滚动节流(Throttle) :虽然滚动监听很快,但在 handleScroll 中加入 requestAnimationFrame 或 20ms 的节流,能有效减轻主线程压力。
  • Key 的选择:在虚拟列表中,key 必须是唯一的 id,绝对不能使用 index,否则在滚动重用 DOM 时会出现状态错乱。

3. 注意事项

  • 定高:逻辑简单,性能极高。
  • 不定高:依赖 ResizeObserver,需注意频繁重排对性能的影响,建议对 updateCumulativeHeights 做异步批处理。

拒绝重复造轮子?我们偏偏花365天,用Vue3写了款AI协同的Word编辑器

ps:开源版SDK已发布,github地址:github.com/MrXujiang/j…

又到了我们定期AI创业复盘的时间了。今天和大家分享一下,我们决定花1年时间打造AI Word编辑器的理由,以及做AI文档创业过程中踩过的坑。图片

为什么"偏偏"要造个Word轮子

我们之所以下定决心做这件事,主要是因为前两年我们的一个产品,需要集成word能力对外服务,但是我们调研了一圈,得到的结论是:1. 大厂的文档产品集成成本过高,对外商业化受限头部大厂做的文档产品,要么是按次付费(并发次数):图片比如上面这张,如果高频使用,我们团队算了一笔账,每年api的调用基本都要花10-20w左右,更别说对外给客户服务了,那成本只有大公司能玩了。另一方面,国内B端客户大部分的Saas场景都需要私有化部署,国企事业金融企业更要求内网部署,所以基本上不可能集成大厂的SDK,这条商业模式已经被堵死了。2. 大厂的文档产品技术债过重,扩展度和开放程度受限这基本上是行业的共识了。普通企业只能用大厂的系统,如果要开发,要么动辄百万,要么就等“等更新”。但是我们的AI文档场景,并不需要很多冗余的功能,而是保留最核心的能力,其他的我们希望开发给企业自定义。同时,现有文档编辑器都是"互联网时代的产物",为"人写给人看"设计。AI无法真正理解文档结构,只能当"高级搜索框"。我们结合这两年AI的发展,洞察的结果是:内容创作正在从"人驱动"转向"人机协作",但工具没有跟上。所以综合上面分析,再结合我们团队大厂架构和文档产品的研发经验,我们毅然决定自研。

365天我们做了什么

图片

说实话,规划了1年,其实并不是单纯的时间维度的概念,我们打算将 JitWord 打造成一个未来我们 all in 的一个产品长链路的方向:基于JitWord的文档引擎,扩展出企业共建版,JitOffice办公软件,JitCloud AI云服务生态。所以可能未来1-2年,我们还是会持续深耕在“知识内容传承”这个赛道。

第一步,架构重构:从"富文本"到"结构化数据"

在做 JitWord 之前,我们对 Office 文档做了大量的调研,从docx格式到文档的复杂操作,都意味着传统的富文本格式(html)难以驾驭复杂的文档场景。

我们也调研了很多开源方案,比如 tiptap,quill,editorjs等。最终我们选择了tiptap的一个早期稳定的版本,作为我们的底层文档组件,并对 tiptap 的架构做了上层的优化,已支持复杂的文档操作。

下面是我们第一版的文档设计架构:

图片

其实单纯实现类似 Office 的UI界面,难度不是很大,只需要花时间来开发,我相信每一个前端工程师都能实现,其难点在于:

  • 如何高效的做文档解析(需要对docx进行高精度格式还原)
  • 如何基于文档做高性能协同(支持多人协同编辑)
  • 如何在web文档处理复杂公式编辑和渲染
  • 实现文档的复杂操作(划词评论,版本对比,高级排版,分页视图等)
  • 实现文档的权限控制和高性能渲染(100w+字秒级渲染)

等等,每一个骨头都比较硬,基本上都是我们花大量实现自研,比如:

基于文档做高性能协同(支持多人协同编辑)

目前市面上其实有些协同方案,比如CRDT算法驱动的YJS,当然我们也是基于YJS实现的文档协同算法。但是单纯使用Yjs,只能实现基础协同,在协同过程中我们需要考虑很多复杂场景:

  1. 操作可交换性不同用户的操作可以以任意顺序执行,最终结果一致
  2. 操作可合并性多个操作可以智能合并,减少网络传输
  3. 最终一致性所有客户端最终会收敛到相同的文档状态
  4. 无需中央协调不依赖中央服务器进行冲突解决

下面是我们设计的协同操作流程:

图片

在协同编辑的性能上,我们也做了进一步算法优化,保证我们在普通服务器上(2核4G)也能支持10-50人的高效协同编辑,如果扩大服务器性能和集群,我们将有可能支持数千上万人的协同编辑。

下面是我们的文档协同和存储架构数据流:

图片

实现在web端处理复杂公式编辑和渲染

基本上市面上的开源方案都达不到我们对复杂公式的极致追求,大部分是让用户直接编辑latex,但是到了导出docx后,公式要么无法导出,要么导出后无法二次编辑。

基于上面的痛点,我们对docx文件做了数据结构的分析和算法层的兼容,同时对用户编辑公式的体验也做了进一步升级:

图片

我们提供了数学公式的编辑面板,我们可以实时编辑和预览公式,同时我们内置了100+高频的数学和科研公式,方便大家更快速的编写复杂公式。

下面是渲染到 JitWord 中的公式效果:

图片

同时,我们也能直接导出带复杂公式的文档,并在 word 中二次编辑。

文档的高效渲染(100w字秒级渲染)

图片

上面是我们文档中渲染了100w字的效果,目前测试下来还是可以编辑文档,只有略微的延迟。

这一结果归功于我们对文档性能的极致追求,在文档渲染层我们做了极致的优化,并全方面测试了渲染协同稳定性能:

图片

实现文档的复杂操作(划词评论,版本对比,高级排版,分页视图等)

任何一个富文本编辑器,都很难自带划词评论,版本对比,高级排版,分页视图这些高级操作功能。

我们研究这些功能花费了很多时间,也在每个月以2-3个版本的迭代速度更新着JitWord。

终于在2025年底实现了上面提到的这几个功能,下面我也给大家一一介绍一下。

  1. 划词评论

图片

当然大家可不要以为我们只是做了划词评论功能。我们在划词评论之后,还做了通信机制,在多人协同过程中可以实时通知给其他人,让协作者可以第一时间看到划词的内容,这个流程我们完全打通了。

  1. 版本管理功能

图片

我们的操作会定期存储,可以一键恢复,并支持版本的差异对比。这个功能基本上也是市面上web端文档独一份的,当然我们还在持续优化。

  1. 分页视图功能

图片

这个功能是最难坑的,不容置疑。但是我们花了2个月 all in 这个功能,终于实现了类似 word 的分页功能。它的难点在于分页之后内容的排版和位置自动计算机制,需要消耗大量的 js 性能,所以我们各种性能优化的方式都用上了,结合我们设计的独有的dom组织模式,终于实现了分页能力。

大家可以在 JitWord 共建版体验到分页的功能。

当然,我们还会支持页眉页脚功能,全面对标 Office。

第二步,AI协作引擎:让文档"活"起来

图片

上面是我们设计的 AI Native 驱动的模式架构,保证我们在文档编辑的全生命周期里,都能体验AI创作的乐趣。

下面是演示效果:

图片

我们除了直接让AI创作内容,还可以基于文本和段落,进行二次AI优化,比如文本润色,纠错,续写等:

图片

最近我们还迭代上线了AI公文助手,可以通过AI一键生成合同和公文:

图片图片

当然后续会实现更多和AI结合的场景,提高用户办公的效率。

第三步,Vue3的执念:为什么死磕Vue?

很多人问:文档编辑器为什么要强调Vue3?用React不是生态更好?

我们的回答是:响应式性能决定了AI协作的流畅度

技术细节:

  • Proxy-based响应式:10万字符文档的实时协作,传统Object.defineProperty会卡成PPT,Vue3的Proxy实现了毫秒级更新
  • Composition API:AI建议卡片、协作光标、公式渲染器……这些复杂组件的组合逻辑,用Composables管理比HOC清晰10倍
  • Tree-shaking友好:最终打包体积比同类React方案小40%,SaaS嵌入时客户感知明显

一个真实案例:

一个客户把我们的产品嵌入他们的CRM系统,原先用的React方案首屏加载3.2s,替换为 JitWord 后降到1.1s。客户CTO的原话:"你们这个Vue3版本,让我们的SaaS看起来像原生应用。 "

这就是"造轮子"的意义:不是为造而造,是为跑得更快。

并且国产化环境,很多客户都是更倾向用 Vue 技术栈,所以站在客户和用户体验的角度,我们Vue的策略是完全经得起考验的。

拒绝自嗨,持续打磨应用场景

技术人最容易犯的错:拿着锤子找钉子。

我们花了两个月时间,把产品扔到真实场景里验证,我们上线了我们开源SDK,让大家可以集成到自己的真实项目中来体验:

图片

目前有各行各业的客户给我们反馈了大量的建议,我们也在持续排期优化,下面分享一下我们内部的需求列表:

图片

目前已经有100多个我们评估后觉得非常有价值的功能点,在今年的一年里,我们会陆续上线。

当然大家有好的建议,也欢迎随时交流反馈~

github地址:github.com/MrXujiang/j…


开源与商业化思考

写到这里,必须回答那个最尖锐的问题:

你们最后要卖钱吗?还是说只是技术情怀?

我的答案是:部分开源,商业闭环。


为什么开源?

开源了基础的文档引擎SDK,包括:

  • 结构化文档模型
  • Vue3组件基础
  • 公式渲染模块
  • docx导入导出功能

目的不是做慈善,是建立标准。

如果 JitWord 的文档模型成为行业事实标准,第三方开发者自然会基于我们的格式开发插件。这等于用社区力量帮我们扩展生态——比招100个产品经理都有效


为什么保留商业版?

商业版包含:

  • AI协作引擎(调用大模型API,成本敏感)
  • 企业级协作功能(权限管理、审计日志)
  • SaaS嵌入解决方案

这不是"开源版阉割功能",是"开源版定义基础,商业版解决复杂问题"。

类比:Android开源,但GMS(Google服务)收费;MySQL开源,但企业版有高级监控。这是经过验证的商业模式。

一个创业者的坦白:

我们考虑过完全开源、靠服务变现。但过去一年和几十家企业聊过后,我发现B端客户真正付费的不是"软件",是"确定性" ——出了问题能找到人、能签SLA、能定制开发。这些只能靠商业团队支撑。

所以,造轮子不是目的。

让轮子跑得更快,让更多人用上更快的轮子,同时让造轮子的人能持续造下去——这才是目的。


关于作者:前大厂技术专家,现 JitWord 联合创始人。相信AI时代的生产力工具,应该由懂产品和技术的人重新做一遍。

我们团队均来自一线中大厂,资深工程师和技术专家,配合3个Agent,开启6人协作的创业之旅~

如果大家有好的建议,想法,欢迎留言反馈~

vue3 页面缓存KeepAlive示例

KeepAlive 示例

1.全部缓存

//APP.vue
<router-view v-slot="{ Component }">
    <KeepAlive>
      <component :is="Component"/>
    </KeepAlive>
</router-view>

2.根据路由配置缓存

  • 通过v-if来实现、配置在路由即可
//router/index.js
const routes = [
  {
    path: '/',
    component: () => import('@/views/home/index.vue'),
    name: 'home',
    meta: {
      title: '首页',
      keepAlive: true,//是否缓存
    },
  },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
//APP.vue
<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive>
      <component :is="Component" :key="$route.fullPath" v-if="route.meta.keepAlive" />
    </keep-alive>
    <component :is="Component" :key="$route.fullPath" v-if="!route.meta.keepAlive" />
  </router-view>
</template>

3.动态控制页面缓存:include

  • 通过include匹配实现;
  • 要求:值要和页面组件的name一致;
  • 方便:为方便取值,页面组件的name要和对应路由name保持一致,直接获取路由的name即可
  • 管理:借助pinia进行管理
  • 缺点:组件需要手动设置name且和路由一致,麻烦点
  • 1、路由:/router/index.js
const routes = [
  {
    path: '/',
    component: () => import('@/views/home/index.vue'),
    name: 'home',//这里和component组件文件的defineOptions({ name: 'home'})保持一致
    meta: {
      title: '首页',
      keepAlive: true,//是否缓存
    },
  },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
  • 2、App.vue
<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive :include="appStore.cacheList">
      <component :is="Component" :key="route.fullPath" />
    </keep-alive>
  </router-view>
</template>
<script setup>
import { useAppStore } from '@/store/app'

const router = useRouter()
const appStore = useAppStore()

// 初始化缓存列表:根据路由配置添加需要缓存的组件
onMounted(() => {
  appStore.initCacheList(router)
})
</script>
<style scoped></style>
  • 3、pinia实现控制逻辑:/store/app.js
import { defineStore } from 'pinia'

/**
 * 应用状态管理 Store
 * 主要用于管理 keep-alive 缓存列表
 */
export const useAppStore = defineStore('app', {
    state: () => ({
        // 缓存列表,存储需要缓存的组件名称
        // keep-alive 的 include 需要匹配组件的 name
        cacheList: []
    }),

    getters: {
        /**
         * 获取缓存列表(只读)
         */
        getCacheList: (state) => {
            return [...state.cacheList]
        },

        /**
         * 检查某个组件是否在缓存列表中
         */
        isCached: (state) => {
            return (componentName) => {
                return state.cacheList.includes(componentName)
            }
        }
    },

    actions: {
        /**
         * 初始化缓存列表:根据路由配置添加需要缓存的组件
         * @param {Object} router - Vue Router 实例
         */
        initCacheList(router) {
            if (!router) {
                console.warn('initCacheList: router 参数不能为空')
                return
            }

            router.getRoutes().forEach(route => {
                if (route.meta?.keepAlive && route.name) {
                    // keep-alive 的 include 需要匹配组件的 name
                    // 这里使用路由 name,需要确保路由 name 和组件 defineOptions 中的 name 一致
                    const componentName = route.name
                    if (!this.cacheList.includes(componentName)) {
                        this.cacheList.push(componentName)
                    }
                }
            })
        },

        /**
         * 添加组件到缓存列表
         * @param {string} componentName - 组件名称
         */
        addCache(componentName) {
            if (!componentName) {
                console.warn('addCache: componentName 参数不能为空')
                return
            }

            if (!this.cacheList.includes(componentName)) {
                this.cacheList.push(componentName)
                console.log(`已添加 ${componentName} 到缓存列表`)
            }
        },

        /**
         * 从缓存列表中移除组件(临时取消缓存)
         * @param {string} componentName - 组件名称
         */
        removeCache(componentName) {
            if (!componentName) {
                console.warn('removeCache: componentName 参数不能为空')
                return
            }

            const index = this.cacheList.indexOf(componentName)
            if (index > -1) {
                this.cacheList.splice(index, 1)
                console.log(`已移除 ${componentName} 的缓存`)
            } else {
                console.warn(`未找到 ${componentName} 的缓存`)
            }
        },

        /**
         * 清空所有缓存
         */
        clearAllCache() {
            this.cacheList = []
        },

        /**
         * 根据路由信息自动管理缓存
         * 如果路由配置了 keepAlive 为 true,则自动添加到缓存列表
         * @param {Object} route - 路由对象
         */
        autoManageCache(route) {
            if (!route) return

            const componentName = route.name
            if (route.meta?.keepAlive && componentName) {
                if (!this.cacheList.includes(componentName)) {
                    this.addCache(componentName)
                }
            }
        }
    }
})

4.解决上面麻烦点

  • 动态赋值组件name为路由的name值
  • 1.在路由页面/router/index.js 使用工具处理
import { processRoutes } from '@/utils/route-helper';//自动为组件设置路由 name的辅助工具
const routes = [....];//如上
// 自动为所有路由组件设置 name(如果组件没有设置 name,则使用路由 name)
const processedRoutes = processRoutes(routes);

const router = createRouter({
  history: createWebHashHistory(),
  routes: processedRoutes,
})
export default router;
  • 2.@/utils/route-helper
/**
 * 路由辅助工具
 * 自动为组件设置路由 name,避免每个页面都手动设置 defineOptions
 */

import { defineComponent, h, markRaw } from 'vue'

/**
 * 包装路由组件,自动设置组件 name 为路由 name
 * @param {Function|Object} component - 组件导入函数或组件对象
 * @param {string} routeName - 路由名称
 * @returns {Function|Object} 包装后的组件
 */
export function withRouteName(component, routeName) {
    if (!routeName) {
        return component
    }

    // 如果是异步组件(函数)
    if (typeof component === 'function') {
        return () => {
            return component().then((module) => {
                const comp = module.default || module

                // 如果组件已经有 name,直接返回
                if (comp.name) {
                    return module
                }

                // 使用 defineComponent 包装组件,设置 name
                const wrappedComponent = defineComponent({
                    name: routeName,
                    setup(props, { slots, attrs }) {
                        // 渲染原组件
                        return () => h(comp, { ...props, ...attrs }, slots)
                    }
                })

                // 标记为原始对象,避免响应式
                markRaw(wrappedComponent)

                // 返回包装后的组件
                // 注意:需要先展开 module 再覆盖 default,否则 module.default 会把 wrappedComponent 覆盖掉
                return {
                    ...module,
                    default: wrappedComponent,
                }
            })
        }
    }

    // 如果是同步组件(对象)
    if (typeof component === 'object' && component !== null) {
        // 如果组件已经有 name,直接返回
        if (component.name) {
            return component
        }

        // 使用 defineComponent 包装组件,设置 name
        const wrappedComponent = defineComponent({
            name: routeName,
            ...component
        })

        markRaw(wrappedComponent)
        return wrappedComponent
    }

    return component
}

/**
 * 批量处理路由配置,自动为组件设置 name
 * @param {Array} routes - 路由配置数组
 * @returns {Array} 处理后的路由配置数组
 */
export function processRoutes(routes) {
    return routes.map(route => {
        // 如果有 name 和 component,则自动设置组件 name
        if (route.name && route.component) {
            route.component = withRouteName(route.component, route.name)
        }

        // 递归处理子路由
        if (route.children && Array.isArray(route.children)) {
            route.children = processRoutes(route.children)
        }

        return route
    })
}

以上已验证

  • 登录后 根据接口返回用户可访问的菜单信息 动态添加的路由 缓存功能同样适用

Vue 2.7 封装全屏弹窗组件:基于命名空间的样式定制

在 Vue 2.7 + Element UI 项目中,封装全屏 Iframe 弹窗常遇到样式覆盖无效的问题。特别是开启 append-to-body 后,弹窗 DOM 位于根节点,常规的 scoped 样式难以生效。

本文介绍一种不依赖 scoped,通过CSS 命名空间来实现安全样式隔离的方案。

1. 核心需求

  • 全屏沉浸:弹窗无边距、无默认内边距。
  • DOM 结构安全:必须使用 append-to-body,防止被父级容器截断。
  • 样式无污染:在全局样式模式下,确保只影响当前组件。

2. 组件实现 (SurveyPortal.vue)

Template 结构

关键在于设置 custom-class。这个唯一的类名将作为我们的“样式防火墙”。

<template>
  <el-dialog
    :visible.sync="dialogVisible"
    :title="title"
    fullscreen
    :append-to-body="true"
    :destroy-on-close="true"
    custom-class="survey-portal-dialog" 
    @close="handleClose"
  >
    <div class="iframe-wrapper" v-loading="loading">
      <iframe
        :src="surveyUrl"
        frameborder="0"
        width="100%"
        height="100%"
        @load="onIframeLoad"
      ></iframe>
    </div>
  </el-dialog>
</template>

Script 逻辑

保持标准的 Vue 2.7 写法,计算属性处理 URL,Watch 处理双向绑定。

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

const props = defineProps({
  visible: Boolean,
  surveyId: { type: [String, Number], required: true },
  title: { type: String, default: '外部页面' }
});

const emit = defineEmits(['update:visible', 'close']);

const dialogVisible = ref(false);
const loading = ref(true);

const surveyUrl = computed(() => `/wj/${props.surveyId}`);

watch(() => props.visible, (val) => {
  dialogVisible.value = val;
  if (val) loading.value = true;
});

watch(dialogVisible, (val) => {
  emit('update:visible', val);
});

const onIframeLoad = () => {
  loading.value = false;
};

const handleClose = () => emit('close');
</script>

3. 样式处理(命名空间隔离法)

由于 append-to-body 将 DOM 移出了组件作用域,我们放弃 scoped,转而使用全局样式。为了防止污染全局,我们将所有样式严格包裹在 custom-class 定义的唯一类名中。

CSS 实现原理

  1. 去掉 scoped:让样式变为全局可见。
  2. 顶层包裹:所有规则必须写在 .survey-portal-dialog 内部。
  3. 覆盖 Element UI:直接选中 .el-dialog__body 进行重置。
<style lang="scss">
/* * 注意:不加 scoped 
 * 通过 ".survey-portal-dialog" 这个唯一类名实现逻辑隔离
 */
.survey-portal-dialog {
  display: flex;
  flex-direction: column;

  /* 1. 修正头部样式 */
  .el-dialog__header {
    padding: 15px 20px;
    border-bottom: 1px solid #ebeef5;
  }

  /* 2. 暴力清除 Body 内边距,实现全屏无缝 */
  .el-dialog__body {
    padding: 0 !important;
    margin: 0 !important;
    flex: 1;
    overflow: hidden;
    height: 100%;
  }

  /* 3. 内部 Iframe 容器高度计算 */
  .iframe-wrapper {
    /* 减去 Header 高度(约54px),避免出现双重滚动条 */
    height: calc(100vh - 54px);
    width: 100%;
    overflow: hidden;
  }
}
</style>

4. 方案优劣分析

  • 优点

    • 极度稳定:不受 Vue Loader 版本或 scoped 穿透语法(/deep/ vs ::v-deep)变更的影响。
    • 符合直觉:完美兼容 append-to-body 的 DOM 移动行为。
  • 注意点

    • 命名唯一性:必须保证 survey-portal-dialog 这个类名在项目中是唯一的,避免与其他弹窗冲突。

5. 总结

在处理 Element UI 的 append-to-body 弹窗时,“全局样式 + 唯一类名包裹”是最简单且副作用最小的方案。它通过 CSS 选择器的嵌套规则,手动建立了一个“样式沙箱”,既解决了全屏覆盖问题,又规避了全局污染风险。

你不知道的 v-on

v-onVue事件绑定指令,近期在使用Vxe Table组件库的时候看见了一个就职公司项目场景不常用的写法,在此分享给同样不常用或不知道的同学们。

//此处复制的是vxetbale组件库的示例代码
<vxe-grid v-bind="gridOptions" v-on="gridEvents"></vxe-grid>

const gridEvents: VxeGridListeners = { 
    pageChange ({ pageSize, currentPage }) { 
        pagerVO.currentPage = currentPage
        pagerVO.pageSize = pageSize
        loadList()
    } 
}

v-on绝大部分人只知道是vue提供的事件绑定api,通常用法:v-on:click="getInfo" 或者简写 @click="handleClick"。在上述案例代码中v-on后面直接就是="gridEvents"这并不是错误写法, 而是v-on对象式事件绑定写法。和常用的 @click="handleClick" 属于同一套事件绑定机制,仅写法形式不同。

两种写法对比

1. 单个事件(常规熟悉写法)

@v-on: 的语法糖,两种写法完全等价:

<button @click="handleClick">点击</button>
<!-- 等价于 -->
<button v-on:click="handleClick">点击</button>

2. 对象式绑定(v-on="对象" 用法)

直接通过 v-on 绑定一个事件对象,适用于多个事件绑定的场景:

<vxe-grid v-on="gridEvents"></vxe-grid>

其中 gridEvents 是一个键值对对象

  • 键:事件名(如 pageChangecellClick
  • 值:该事件对应的处理函数Vue 会自动遍历这个对象,将每个键值对解析为「v-on:事件名=处理函数」的形式完成绑定。

在代码中的实际含义

vxe-table的分页事件为例,实际定义的事件对象如下(包含TypeScript类型约束):

const gridEvents: VxeGridListeners = {
  pageChange({ pageSize, currentPage }) {
    pagerVO.currentPage = currentPage
    pagerVO.pageSize = pageSize
    loadList()
  },
}

此时 v-on="gridEvents" 完全等价于单个事件绑定的写法

<vxe-grid v-on:pageChange="gridEvents.pageChange"></vxe-grid>

如果 gridEvents 中包含多个事件Vue会自动完成所有事件的批量绑定,例如:

// 包含多个事件的处理对象
const gridEvents = {
  pageChange: (e) => { ... },
  editClosed: (e) => { ... },
  cellClick: (e) => { ... },
}

等价于手动为每个事件单独绑定:

<vxe-grid
  v-on:pageChange="gridEvents.pageChange"
  v-on:editClosed="gridEvents.editClosed"
  v-on:cellClick="gridEvents.cellClick"
></vxe-grid>

为什么使用对象式事件绑定写法?

  1. 事件多时更简洁:无需在模板中重复书写大量 v-on:xxx="xxx",仅需一个 v-on="对象" 即可完成批量绑定,简化模板代码;
  2. 便于维护:所有事件的处理函数都集中在一个对象中,事件名和对应逻辑一一对应,后续新增 / 修改 / 删除事件时,只需操作该对象,无需改动模板;
  3. 适配组件库场景vxe-tableElement Plus这类 UI 组件库的复杂组件(如表格、树形控件)通常提供大量事件,使用对象统一配置事件,代码结构会更清晰。

以上便是对v-on的分享,欢迎大家指正讨论,与大家共勉。

Vue 3 + Vite 集成 Monaco Editor 开发笔记

背景:在 Vue 3 (JS/TS) + Vite 项目中集成代码编辑器 Monaco Editor。

目标:实现代码高亮、自定义主题、中文汉化、语言切换、双向绑定等功能。

1. 方案选择与安装

在 Vite 中集成 Monaco Editor 主要有两种方式:

  1. 原生 Worker 方式:最稳定,利用 Vite 的 ?worker 特性,但汉化极其困难。
  2. 插件方式 (vite-plugin-monaco-editor)推荐。配置简单,自带汉化支持,但需要处理导入兼容性问题。

安装依赖

Bash

# 核心库
npm install monaco-editor

# Vite 插件 (用于处理 Worker 和汉化)
npm install -D vite-plugin-monaco-editor

2. 核心配置 (Vite)

🔴 常见报错与修复

在使用插件时,可能会遇到以下报错:

  1. TypeError: monacoEditorPlugin is not a function
  2. TypeError: Cannot read properties of undefined (reading 'entry')

这是因为 ESM/CommonJS 模块导入兼容性问题。

✅ 最佳配置 (vite.config.js)

JavaScript

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

export default defineConfig({
  plugins: [
    vue(),
    // 🟢 核心修复:兼容写法,防止报错
    (monacoEditorPlugin.default || monacoEditorPlugin)({
        // 需要加载 Worker 的语言 (JSON, TS/JS, HTML, CSS 有独立 Worker)
        languageWorkers: ['json', 'editorWorkerService'],
        // 🟢 开启中文汉化
        locale: 'zh-cn', 
    })
  ],
})

注意SQL 属于 Basic Language(基础语言),没有独立的 Worker,不需要加到 languageWorkers 列表中。


3. 组件封装 (MonacoEditor.vue)

封装一个支持 双向绑定 (v-model)语言切换自定义主题 的通用组件。

核心逻辑点:

  1. 主题生效顺序:必须先 defineTheme,再 create 实例,并在配置中显式指定 theme
  2. 语言切换:使用 monaco.editor.setModelLanguage 动态切换。
  3. 双向绑定:同时支持内容 (v-model) 和 语言 (v-model:language)。

完整代码

代码段

<template>
  <div class="monaco-wrapper">
    <select :value="language" class="lang-select" @change="handleLanguageChange">
      <option value="json">JSON</option>
      <option value="sql">SQL</option>
      <option value="javascript">JS</option>
      <option value="css">CSS</option>
    </select>

    <div ref="editorContainer" class="editor-container"></div>
  </div>
</template>

<script setup>
import { onMounted, onBeforeUnmount, ref, watch, toRaw } from 'vue'
import * as monaco from 'monaco-editor'

// 定义 Props
const props = defineProps({
  modelValue: { type: String, default: '' },
  language: { type: String, default: 'json' },
  readOnly: { type: Boolean, default: false }
})

// 定义 Emits (支持双 v-model)
const emit = defineEmits(['update:modelValue', 'update:language', 'change'])

const editorContainer = ref(null)
let editorInstance = null

// 1. 切换语言逻辑
const handleLanguageChange = (e) => {
  const newLang = e.target.value
  emit('update:language', newLang) // 通知父组件
  if (editorInstance) {
    monaco.editor.setModelLanguage(editorInstance.getModel(), newLang)
  }
}

onMounted(() => {
  if (!editorContainer.value) return

  // 2. 定义自定义主题 (必须在 create 之前)
  monaco.editor.defineTheme('my-dark-theme', {
    base: 'vs-dark',
    inherit: true,
    rules: [
      { token: 'key', foreground: 'dddddd' },
      { token: 'string.key.json', foreground: 'dddddd' },
      { token: 'string.value.json', foreground: 'b4e98c' },
    ],
    colors: {
      'editor.background': '#0e1013', // 背景色
      'editor.lineHighlightBackground': '#1f2329',
    },
  })

  // 3. 创建编辑器实例
  editorInstance = monaco.editor.create(editorContainer.value, {
    value: props.modelValue,
    language: props.language,
    theme: 'my-dark-theme', // 🟢 显式引用主题
    readOnly: props.readOnly,
    automaticLayout: true, // 自动适应宽高
    minimap: { enabled: false }, // 关闭小地图
    scrollBeyondLastLine: false,
  })

  // 4. 监听内容变化 -> 通知父组件
  editorInstance.onDidChangeModelContent(() => {
    const value = editorInstance.getValue()
    emit('update:modelValue', value)
    emit('change', value)
  })
})

// 5. 监听 Props 变化 (外部修改 -> 同步到编辑器)
watch(() => props.modelValue, (newValue) => {
  if (editorInstance && newValue !== editorInstance.getValue()) {
    // toRaw 避免 Vue 代理对象干扰 Monaco 内部逻辑
    toRaw(editorInstance).setValue(newValue)
  }
})

watch(() => props.language, (newLang) => {
  if (editorInstance) {
    monaco.editor.setModelLanguage(editorInstance.getModel(), newLang)
  }
})

// 销毁
onBeforeUnmount(() => {
  editorInstance?.dispose()
})
</script>

<style scoped>
.monaco-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
  min-height: 300px;
}
.editor-container {
  width: 100%;
  height: 100%;
}
.lang-select {
  position: absolute;
  right: 15px;
  top: 10px;
  z-index: 20;
  background: #1f2329;
  color: #ddd;
  border: 1px solid #555;
  border-radius: 4px;
}
</style>

4. 疑难杂症 (Q&A)

Q1: 为什么在 node_modules 里找不到 SQL 的 Worker 文件?

  • 原因:Monaco 将语言分为两类。

    • Rich Languages (JSON, TS, CSS, HTML):有独立 Worker,支持高级语法检查。路径在 esm/vs/language
    • Basic Languages (SQL, Python, Java 等):没有独立 Worker,只依靠主线程进行简单高亮。路径在 esm/vs/basic-languages
  • 结论:配置插件时,languageWorkers 不需要加 SQL。

Q2: 为什么 import 'monaco-editor/esm/nls.messages.zh-cn.js' 汉化不生效?

  • 原因:在 ESM 模式下,编辑器核心初始化往往早于语言包加载,或者直接被 Tree-shaking 忽略。
  • 解决:使用 vite-plugin-monaco-editor 并配置 locale: 'zh-cn',插件会在编译构建阶段自动注入语言包。

Q3: 为什么 Ctrl+点击 @/... 路径无法跳转?

  • 原因:VS Code 需要配置文件来理解别名。对于 Vue+JS 项目,根目录缺少 jsconfig.json

  • 解决:在根目录创建 jsconfig.json

    JSON

    {
      "compilerOptions": {
        "baseUrl": ".",
        "paths": { "@/*": ["src/*"] }
      },
      "include": ["src/**/*"]
    }
    

    设置完后记得重启 VS Code。


5. 最佳实践:父组件调用

使用 Vue 3 的多 v-model 特性,代码语义最清晰:

HTML

<template>
  <div class="page">
    <MonacoEditor 
      v-model="codeContent" 
      v-model:language="currentLang" 
    />
    
    <button @click="runCode">运行</button>
  </div>
</template>

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

const codeContent = ref('SELECT * FROM users;')
const currentLang = ref('sql') // 切换下拉框会自动更新此变量

const runCode = () => {
  console.log(`正在运行 ${currentLang.value} 代码:`, codeContent.value)
}
</script>

uni-app使用非uni_modules的ucharts组件,本地运行没问题,部署到线上出问题?

问题背景:使用非uni_modules的ucharts组件,本地运行没用问题,发布为h5后未见报错,但ucharts却始终出不来。

步骤复现

  1. 手上有个需求,需要使用uni-app开发微信小程序,初始时使用h5作为演示系统,需求里面存在图表展示功能。这时,网上去找对应适合的charts组件,发现ucharts可以用于移动端图表展示。

  2. 引入官方非uni_modules组件(官方有说明文档),进行开发。 image.png

  3. 按照步骤引入qiun-data-charts.vue组件后,本地运行可以正常出来,也没见报错,这时build为h5后发布到服务器,神奇的一幕出现了,charts竟然出不来!!!赶紧某度,甚至上了AI,但都没解决问题,后面我就琢磨是否配置有问题,比如opts或者eopts出问题了,但对比了一下官方api均未发现问题,只能去翻ucharts源码。

  4. 看了一下源码后发现有一段代码用到了路径,就怀疑是不是路径解析出了问题,加了个打印再次部署查看,发现果真多了个./ 然后去找了一下为什么会多出这个东西,原因是打包的时候配置了指定资源打包路径,重新修改ucharts资源路径之后就可以正常出来了。 image.pngimage.pngimage.png

以上内容仅供参考

前端工程化 - Vite初始化Vue项目及代码规范配置

前端工程化是通过工具和规范,提升开发效率、代码质量和团队协作的系统化方案。大致包含以下内容:

  • 代码规范
  • Git Hooks
  • 环境变量
  • 构建优化

本文内容包含

  1. 使用 vite 创建 vue 项目
  2. 配置代码规范及相关格式化

一、使用vite创建vue项目

初始化项目

pnpm create vue

图片

按需完善项目结构

图片

设置别名

修改vite.config.ts

import path from 'path'

...
resolve: {
    alias: {
        '@': path.resolve(__dirname, 'src'),
    }
}
...

修改tsconfig.app.json

{
    "compilerOptions": {
        "baseUrl": ".",
        "paths": {
            "@/*": ["src/*"]
        },
    }
}

为项目添加自动导入

pnpm add -D unplugin-auto-import unplugin-vue-components

修改vite.config.ts

import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'

export default defineConfig({
    plugins: [
        vue(),
        // 新增
        AutoImport({
            imports: ['vue'],
            dts: './src/auto-imports.d.ts',
            eslintrc: {
                enabled: true,
                filepath: './src/.eslintrc-auto-import.json',
            }
        }),
        // 新增
        Components({
            dirs: ['src/components'],
            extensions: ['vue'],
            deep: true,
            dts: './src/components.d.ts',
            resolvers: []
        })
    ]
})

修改tsconfig.app.json

{
  ...
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "src/auto-imports.d.ts",   // 新增
    "src/components.d.ts"     // 新增
  ]
}

二、配置代码规范及相关格式化

配置格式化校验

统一代码风格,自动检查常见错误和潜在问题

  • ESLint: 代码质量检查(语法、最佳实践)

    ESLint 9.x 不再支持 .eslintrc.*,需要使用新的扁平配置格式 eslint.config.js

  • Prettier: 代码格式化(缩紧、引号、分号等)

  • 依赖:

pnpm add -D \
eslint \
@typescript-eslint/parser \
@typescript-eslint/eslint-plugin \
eslint-plugin-vue \
@eslint/js \
vue-eslint-parser \
prettier \
eslint-config-prettier \
eslint-plugin-prettier
  • 配置文件: eslint.config.js.prettierrc.cjs.prettierignore
  • 脚本:在package.json中添加检验和格式化命令

添加eslint.config.js

import js from '@eslint/js'
import tsPlugin from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import vueParser from 'vue-eslint-parser'
import vuePlugin from 'eslint-plugin-vue'
import prettierConfig from 'eslint-config-prettier'
import prettierPlugin from 'eslint-plugin-prettier'

exportdefault [
    // 基础配置
    js.configs.recommended,

    // 全局忽略
    {
        ignores: ['node_modules/**', 'dist/**', '*.config.*', 'pnpm-lock.yaml'],
    },

    // vue文件配置
    {
        files: ['**/*.vue'],
        languageOptions: {
            parser: vueParser,
            parserOptions: {
                parser: tsParser,
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                console: 'readonly',
                process: 'readonly',
            },
        },
        plugins: {
            vue: vuePlugin,
            '@typescript-eslint': tsPlugin,
            prettier: prettierPlugin,
        },
        /**
         * "off" 或 0    ==>  关闭规则
         * "warn" 或 1   ==>  打开的规则作为警告(不影响代码执行)
         * "error" 或 2  ==>  规则作为一个错误(代码不能执行,界面报错)
         */
        rules: {
            ...prettierConfig.rules,

            // eslint 规则
            'no-var': 'error', // 要求使用 let 或 const 而不是 var
            'no-multiple-empty-lines': ['error', { max: 1 }], // 不允许多个空行
            'prefer-const': 'off', // 使用 let 关键字声明但在初始分配后从未重新分配的变量,要求使用 const
            'no-use-before-define': 'off', // 禁止在 函数/类/变量 定义之前使用它们
            'no-param-reassign': ['error', { props: false }], // 禁止修改函数参数
            'max-classes-per-file': 'off', // 禁止类超过一个文件

            // typescript 规则
            '@typescript-eslint/no-unused-vars': 'error', // 禁止定义未使用的变量
            '@typescript-eslint/no-empty-function': 'error', // 禁止空函数
            '@typescript-eslint/prefer-ts-expect-error': 'error', // 禁止使用 @ts-ignore
            '@typescript-eslint/ban-ts-comment': 'error', // 禁止 @ts-<directive> 使用注释或要求在指令后进行描述
            '@typescript-eslint/no-inferrable-types': 'off', // 禁止对初始化为数字、字符串或布尔值的变量或参数进行显式类型声明
            '@typescript-eslint/no-namespace': 'off', // 禁止使用 namespace 声明
            '@typescript-eslint/no-explicit-any': 'off', // 禁止使用 any 类型
            '@typescript-eslint/ban-types': 'off', // 禁止使用 any 类型
            '@typescript-eslint/no-var-requires': 'off', // 禁止使用 require 语句
            '@typescript-eslint/no-non-null-assertion': 'off', // 禁止使用 ! 断言
            '@typescript-eslint/no-use-before-define': [
              'error',
              {
                functions: false,
              },
            ],

            // vue 规则
            // 'vue/script-setup-uses-vars': 'error', // 要求在 script setup 中使用已定义的变量
            'vue/v-slot-style': 'error', // 要求 v-slot 指令的写法正确
            'vue/no-mutating-props': 'error', // 禁止修改组件的 props
            'vue/custom-event-name-casing': 'error', // 要求自定义事件名称符合 kebab-case 规范
            'vue/html-closing-bracket-newline': 'off', // 要求 HTML 闭合标签换行
            'vue/attribute-hyphenation': 'error', // 对模板中的自定义组件强制执行属性命名样式:my-prop="prop"
            'vue/attributes-order': 'off', // vue api使用顺序,强制执行属性顺序
            'vue/no-v-html': 'off', // 禁止使用 v-html
            'vue/require-default-prop': 'off', // 此规则要求为每个 prop 为必填时,必须提供默认值
            'vue/multi-word-component-names': 'off', // 要求组件名称始终为 “-” 链接的单词
            'vue/no-setup-props-destructure': 'off', // 禁止解构 props 传递给 setup
            'vue/max-len': 0, // 强制所有行都小于 80 个字符
            'vue/singleline-html-element-content-newline': 0, // 强制单行元素的内容折行
            
            // Prettier 规则
            'prettier/prettier': 'error', // 强制使用 prettier 格式化代码
        }
    },
    // js文件配置
    {
        files: ['**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts}'],
        languageOptions: {
            parser: tsParser,
            parserOptions: {
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                console: 'readonly',
                process: 'readonly',
            }
        },
        plugins: {
            '@typescript-eslint': tsPlugin,
            prettier: prettierPlugin,
        },
        rules: {
            ...prettierConfig.rules,

            // eslint 规则
            'no-var': 'error', // 要求使用 let 或 const 而不是 var
            'no-multiple-empty-lines': ['error', { max: 1 }], // 不允许多个空行
            'prefer-const': 'off', // 使用 let 关键字声明但在初始分配后从未重新分配的变量,要求使用 const
            'no-use-before-define': 'off', // 禁止在 函数/类/变量 定义之前使用它们
            'prettier/prettier': 'error', // 强制使用 prettier 格式化代码

            // TypeScript 规则
            '@typescript-eslint/no-unused-vars': 'error',
            '@typescript-eslint/no-empty-function': 'error',
            '@typescript-eslint/prefer-ts-expect-error': 'error',
            '@typescript-eslint/ban-ts-comment': 'error',
            '@typescript-eslint/no-inferrable-types': 'off',
            '@typescript-eslint/no-namespace': 'off',
            '@typescript-eslint/no-explicit-any': 'off',
            '@typescript-eslint/ban-types': 'off',
            '@typescript-eslint/no-var-requires': 'off',
            '@typescript-eslint/no-non-null-assertion': 'off',
            '@typescript-eslint/no-use-before-define': [
                'error',
                {
                    functions: false,
                },
            ],
            
            'prettier/prettier': 'error',
        }
    }
]

添加.prettierrc.cjs

/**
 * Prettier 代码格式化配置
 * 文档:https://prettier.io/docs/en/configuration.html
 */
module.exports= {
  // 是否在语句末尾添加分号
  semi: false,
  // 是否使用单引号
  singleQuote: true,
  // 设置缩进
  tabWidth: 2,
  // 尾随逗号
  trailingComma: 'es5',
  // 每行最大字符数
  printWidth: 120,
  // 箭头函数参数括号: avoid( 避免 ) | always( 总是 )
  arrowParens: 'avoid',
  // 文件行尾: lf( 换行 ) | crlf( 回车换行 ) | auto( 自动 )
  endOfLine: 'lf',
}

添加.prettierignore

node_modules
dist
*.specstory
*.local
pnpm-lock.yaml
package-lock.json
.DS_Store
coverage
.vscode
.idea
public

package.json中添加相关scripts

...
"scripts": {
  ...
    "lint": "eslint . --fix",
    "format": "prettier --write "src/**/*.{js,ts,vue,json,css,scss,md}"",
    "lint:check": "eslint .",
    "format:check": "prettier --check "src/**/*.{js,ts,vue,json,css,scss,md}""
},
...

配置css格式校验及其他

  • Stylelint: css/scss样式校验和格式化,统一样式代码风格,发现样式错误
  • EditorConfig: 统一编辑器配置,保证跨编辑器保持一致的编码风格
  • Commitlint: Git 提交信息格式校验,规范提交信息,便于追踪和生成changeling
  • Husky + lint-staged: Git hooks 自动化校验,代码提交前自动检查,避免提交不符合规范的代码
  1. 安装相关依赖

    # 基础依赖(必需)
    # stylelint-config-html: HTML/Vue模板样式格式化
    # stylelint-config-recess-order: css属性书写顺序
    # stylelint-config-recommended-vue: Vue推荐配置
    pnpm add -D \
      stylelint \
      stylelint-config-standard \
      stylelint-config-standard-vue \
      stylelint-config-prettier \
      stylelint-config-html \ 
      stylelint-config-recess-order \  
      stylelint-config-recommended-vue \ 
      @commitlint/cli \
      @commitlint/config-conventional \
      husky \
      lint-staged \
      postcss-html 
    
    
    # 可选依赖(根据项目需要)
    # 如果使用 Tailwind CSS
    pnpm add -D stylelint-config-tailwindcss
    
    # 如果使用SCSS
    pnpm add -D stylelint-config-standard-scss stylelint-scss
    
  1. 创建.stylelintrc.cjs

    module.exports= {
      // 继承规则
      extends: [
        'stylelint-config-standard', // 配置 stylelint 拓展插件
        'stylelint-config-html/vue', // 配置 vue 中 template 样式格式化
        'stylelint-config-recess-order', // 配置 stylelint css 属性书写顺序插件,
        'stylelint-config-standard-scss', // 配置 stylelint scss 插件
        'stylelint-config-recommended-vue/scss', // 配置 vue 中 scss 样式格式化
        'stylelint-config-tailwindcss',
      ],
      overrides: [
        // 扫描 .vue/html 文件中的 <style> 标签内的样式
        {
          files: ['**/*.{vue,html}'],
          // 使用 postcss-html 解析器
          customSyntax: 'postcss-html',
        },
      ],
      rules: {
        'keyframes-name-pattern': null, // 强制关键帧名称的格式
        'custom-property-pattern': null, // 强制自定义属性的格式
        'selector-id-pattern': null, // 强制选择器 ID 的格式
        'declaration-block-no-redundant-longhand-properties': null, // 禁止冗余的长属性
        'function-url-quotes': 'always', // URL 的引号 "always(必须加上引号)"|"never(没有引号)"
        'color-hex-length': 'long', // 指定 16 进制颜色的简写或扩写 "short(16进制简写)"|"long(16进制扩写)"
        'rule-empty-line-before': 'never', // 要求或禁止在规则之前的空行 "always(规则之前必须始终有一个空行)"|"never(规则前绝不能有空行)"|"always-multi-line(多行规则之前必须始终有一个空行)"|"never-multi-line(多行规则之前绝不能有空行)"
        'font-family-no-missing-generic-family-keyword': null, // 禁止在字体族名称列表中缺少通用字体族关键字
        'property-no-unknown': null, // 禁止未知的属性
        'no-empty-source': null, // 禁止空源码
        'selector-class-pattern': null, // 强制选择器类名的格式
        'value-no-vendor-prefix': null, // 关闭 vendor-prefix (为了解决多行省略 -webkit-box)
        'no-descending-specificity': null, // 不允许较低特异性的选择器出现在覆盖较高特异性的选择器
        // 禁止未知的伪类
        'selector-pseudo-class-no-unknown': [
          true,
          {
            ignorePseudoClasses: ['global', 'v-deep', 'deep'],
          },
        ],
        // 禁止未知的 at-rule
        'scss/at-rule-no-unknown': [
          true,
          {
            ignoreAtRules: ['tailwind', 'apply'],
          },
        ],
        // 禁止未知的函数
        'function-no-unknown': [
          true,
          {
            ignoreFunctions: ['constant'],
          },
        ],
      },
      ignoreFiles: ['**/*.js', '**/*.ts', '**/*.jsx', '**/*.tsx', 'node_modules/**', 'dist/**'],
    }
    
  1. 创建.editorconfig

    # EditorConfig 是帮助多个编辑器和 IDE 维护一致的编码样式的配置文件
    # https://editorconfig.org
    
    root = true
    
    [*] # 表示所有文件适用
    charset = utf-8 # 设置文件字符集为 utf-8
    end_of_line = lf # 设置文件行尾为 LF
    indent_style = space # 缩进风格(tab | space)
    indent_size = 2 # 缩进大小
    insert_final_newline = true # 在文件末尾插入一个新行
    trim_trailing_whitespace = true # 删除行尾的空格
    max_line_length = 130 # 最大行长度
    
    [*.md] # 表示仅对 md 文件适用以下规则
    max_line_length = off # 关闭最大行长度限制
    trim_trailing_whitespace = false # 关闭末尾空格修剪
    
    [*.{yml,yaml}]
    indent_size = 2 # 设置 yaml 文件的缩进大小为 2
    
    [Makefile]
    indent_style = tab # 设置 Makefile 文件的缩进风格为 tab
    
  1. 创建commitlint.config.js文件

    exportdefault {
        extends: ['@commitlint/config-conventional'],
        rules: {
            'type-enum': [
                2,
                'always',
                [
                    'feat', // 新功能
                    'fix', // 修复问题
                    'docs', // 文档更新
                    'style', // 代码格式(不影响代码运行的变动)
                    'refactor', // 重构代码(既不是新增功能,也不是修复问题的代码变动)
                    'perf', // 性能优化
                    'test', // 添加测试
                    'chore', // 构建过程或辅助工具的变动
                    'build', // 打包
                    'ci', // CI配置
                    'revert', // 回退
                    'release', // 发布
                    'wip', // 开发中
                ]
            ],
            // 类型必须小写
            'type-case': [
                2,
                'always',
                'lower-case'
            ],
            // 类型不能为空
            'type-empty': [2, 'never'],
            // 作用域必须小写
            'scope-case': [
                2,
                'always',
                'lower-case'
            ],
            // 主题必须小写
            'subject-case': [
                2,
                'always',
                'lower-case'
            ],
            // 头部最大长度为 100 个字符
            'header-max-length': [
                2,
                'always',
                100
            ],
            // 主体前面必须有一个空行
            'body-leading-blank': [
                2,
                'always'
            ],
        }
    }
    
  1. 创建.lintstagedrc.js

    exportdefault {
      '*.{js,jsx,ts,tsx,vue}': ['eslint --fix', 'prettier --write'],
      '*.{css,scss,less,styl}': ['stylelint --fix', 'prettier --write'],
      '*.{json,md,yml,yaml}': ['prettier --write'],
    }
    
  1. 更新package.json

    {
    ...
    "scripts": {
        "lint": "eslint . --fix",
        "format": "prettier --write "src/**/*.{js,ts,vue,json,css,scss,md}"",
        "lint:check": "eslint .",
        "format:check": "prettier --check "src/**/*.{js,ts,vue,json,css,scss,md}"",
    
        "lint:style": "stylelint "**/*.{css,scss,vue}" --fix",
        "lint:style:check": "stylelint "**/*.{css,scss,vue}"",
    
        "type-check": "vue-tsc --noEmit",
    
        "check": "pnpm lint:check && pnpm format:check && pnpm lint:style:check && pnpm type-check",
        "fix": "pnpm lint && pnpm format && pnpm lint:style",
    
        "prepare": "husky install"
    },
    ...
    }
    
  2. 初始化Husky(Git Hooks)

    pnpm prepare
    

    这会在根目录下生成.husky目录,其中包含了_子目录,将子目录下的commit-msgpre-commit文件拷贝到.husky目录下,并修改文件内容如下:

    .husky/commit-msg文件内容

    #!/usr/bin/env sh
    . "$(dirname -- "$0") /_/husky.sh"
    
    npx --no -- commitlint --edit $1
    

    .husky/pre-commit文件内容

    #!/usr/bin/env sh
    . "$(dirname -- "$0")/_/husky.sh"
    
    pnpm lint-staged
    
  3. 验证配置文件语法

    如果某些验证失败,请检查:

    • 依赖是否已正确安装

    • 配置文件语法是否正确

    • 文件路径是否正确

    # 1. 验证 ESLint 配置
    pnpm exec eslint --print-config src/App.vue > /dev/null && echo "✅ ESLint 配置正确" || echo "❌ ESLint 配置有误"
    
    # 2. 验证 Prettier 配置
    pnpm exec prettier --check . > /dev/null 2>&1 && echo "✅ Prettier 配置正确" || echo "⚠️  Prettier 发现格式问题(这是正常的)"
    
    # 3. 验证 Stylelint 配置
    pnpm exec stylelint --print-config src/style.css > /dev/null && echo "✅ Stylelint 配置正确" || echo "❌ Stylelint 配置有误"
    
    # 4. 验证 Commitlint 配置
    pnpm exec commitlint --help > /dev/null && echo "✅ Commitlint 已安装" || echo "❌ Commitlint 未安装"
    
    # 5. 验证 TypeScript 配置
    pnpm exec vue-tsc --version && echo "✅ vue-tsc 已安装" || echo "❌ vue-tsc 未安装"
    

    图片

  1. 运行检查命令

    # 1. 检查代码格式(ESLint)
    pnpm lint:check
    
    # 2. 检查代码格式(Prettier)
    pnpm format:check
    
    # 3. 检查样式格式(Stylelint)
    pnpm lint:style:check
    
    # 4. 检查 TypeScript 类型
    pnpm type-check
    
    # 5. 综合检查(运行所有检查)
    pnpm check
    
    # 6. 自动修复
    pnpm fix
    

配置文件保存时自动格式化

  1. 安装相关插件
    • Prettier - Code formatter
    • ESLint
    • Stylelint
    • Volar
    • TypeScript Vue Plugin

图片

  1. 创建.vscode/setting.json

    {
      // 编辑器基础配置
      "editor.formatOnSave": true,
      "editor.defaultFormatter": "esbenp.prettier-vscode",
      "editor.codeActionsOnSave": {
        "source.fixAll.eslint": "explicit",
        "source.fixAll.stylelint": "explicit"
      },
    
      // Vue 文件特殊配置 - 使用 Volar 格式化
      "[vue]": {
        "editor.defaultFormatter": "Vue.volar",
        "editor.formatOnSave": true,
        "editor.codeActionsOnSave": {
          "source.fixAll.eslint": "explicit",
          "source.fixAll.stylelint": "explicit"
        }
      },
    
      // Volar 配置
      "volar.formatting.printWidth": 120,
      "volar.formatting.singleQuote": true,
      "volar.formatting.semi": false,
      "volar.formatting.tabSize": 2,
      "volar.formatting.trailingComma": "es5",
      "volar.formatting.arrowParens": "avoid",
      "volar.formatting.endOfLine": "lf",
    
      // 或者使用 Prettier 格式化 Vue(需要配置)
      // "[vue]": {
      //   "editor.defaultFormatter": "esbenp.prettier-vscode",
      //   "editor.formatOnSave": true
      // },
    
      // 文件类型特定配置
      "[javascript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[javascriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[typescript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[typescriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[json]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[jsonc]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[css]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[scss]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[less]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[html]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[markdown]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
    
      // ESLint 配置
      "eslint.enable": true,
      "eslint.validate": [
        "javascript",
        "javascriptreact",
        "typescript",
        "typescriptreact",
        "vue"
      ],
      "eslint.format.enable": true,
      "eslint.codeAction.showDocumentation": {
        "enable": true
      },
    
      // Stylelint 配置
      "stylelint.enable": true,
      "stylelint.validate": [
        "css",
        "scss",
        "less",
        "vue"
      ],
    
      // Prettier 配置
      "prettier.enable": true,
      "prettier.requireConfig": true,
      "prettier.configPath": ".prettierrc.cjs",
    
      // 使用 Prettier 格式化 Vue(如果使用 Prettier 而不是 Volar)
      "prettier.documentSelectors": ["**/*.vue"],
    
      // 其他编辑器配置
      "files.eol": "\n",
      "files.insertFinalNewline": true,
      "files.trimTrailingWhitespace": true,
      "files.encoding": "utf8",
    
      // Vue 相关配置 - 禁用 Vetur(如果安装了)
      "vetur.format.enable": false,
      "vetur.validation.template": false,
      "vetur.validation.script": false,
      "vetur.validation.style": false,
    
      // TypeScript 配置
      "typescript.tsdk": "node_modules/typescript/lib",
      "typescript.enablePromptUseWorkspaceTsdk": true
    }
    
  1. 验证配置

    打开任意.vuets.js文件,故意写一些格式不规范的代码(例如:多余空格,缺少分号等),保存文件,检查代码是否自动格式化

常见问题:

1. 保存时格式化不生效

  • 检查 VSCode 扩展是否已安装
  • 检查 .vscode/settings.json 是否正确配置
  • 重启 VSCode 或重新加载窗口

2. ESLint 报错找不到模块

  • 运行 pnpm install 重新安装依赖
  • 检查 eslint.config.js 中的导入路径

3. Git Hooks 不生效

  • 检查 .husky/pre-commit.husky/commit-msg 文件是否存在且可执行
  • 运行 chmod +x .husky/pre-commit .husky/commit-msg 添加执行权限

总结

通过以上配置,我们已经为 Vue 3 + TypeScript + Vite 项目搭建了完整的代码规范体系:

代码质量检查:ESLint + TypeScript 类型检查

代码格式化:Prettier

样式规范:Stylelint + EditorConfig

提交规范:Commitlint + Husky + lint-staged

开发体验:VSCode 保存自动格式化

配置清单

项目根目录下应包含以下配置文件:

  • eslint.config.js - ESLint 配置
  • .prettierrc.cjs - Prettier 配置
  • .prettierignore - Prettier 忽略文件
  • .stylelintrc.cjs - Stylelint 配置
  • .editorconfig - 编辑器配置
  • commitlint.config.js - Commitlint 配置
  • .lintstagedrc.js - lint-staged 配置
  • .husky/pre-commit - Git pre-commit hook
  • .husky/commit-msg - Git commit-msg hook
  • .vscode/settings.json - VSCode 工作区配置

相关资源

📦 完整示例: GitHub 仓库地址

❌