普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月20日首页

Ant Design Vue 表格组件空数据统一处理 踩坑

作者 28256_
2026年3月20日 18:06

transformCellText

提供 transformCellText 这个表格属性来做数据的处理

transformCellText 数据渲染前可以再次改变,一般用于空数据的默认配置 Function({ text, column, record, index }) => any,此处的 text 是经过其它定义单元格 api 处理后的数据,有可能是 VNode/string/number 类型

数据处理时,都是用text这个属性

划重点

text会有两种情况,这个才是坑的地方

  • 非数组(直接就是要展示的数据)
  • 是个数组(要展示的数据被数组包裹了一层)

text非数组情况


<a-table :dataSource="dataSource" :columns="columns" />

直接简单使用,不使用table组件的插槽,这个时候返回的就是要展示的数据

image.png 可以从图上看出,打印的text的结果

text是个数组


<template>
  <a-table :dataSource="dataSource" :columns="columns" :transformCellText="ssss">
    <template #bodyCell="{ column, record }">
      <template v-if="column.key === 'avatar'">
        <a-avatar :src="record.avatar" :style="{ backgroundColor: '#1890ff' }">
          {{ record.name?.charAt(0) }}
        </a-avatar>
      </template>
    </template>
  </a-table>
</template>

使用了table组件的bodyCell插槽,这个时候要展示的数据被数组包裹了一层

image.png 可以从图上看出,打印的text被数组包裹了一层

实践方案

既然text会有两种情况,就可以从两种情况下手,完成我们的需求

// 当返回的类型是VNode时,不用特殊处理,因为VNode是自定义的dom 直接渲染
const handleTransform = ({ text }) => {

  const isEmpty = val => val === null || val === undefined || val === ''

  const target = Array.isArray(text) ? (text.length > 0 ? text[0] : undefined) : text

  return isEmpty(target) ? '--' : text
}

Vite 核心原理:ESM 带来的开发时“瞬移”体验

作者 wuhen_n
2026年3月19日 10:18

前言

还记得用Webpack开发时的日常吗? 控制台输入 npm run dev ,等待 30 秒后项目终于启动了 ;过了一会儿,修改了一个文件,保存,等待 10 秒之后热更新完成;后来项目变大了,每次保存要等 20 秒以上...

这是 Webpack 时代的真实写照,而 Vite 的出现,彻底改变了这一切: 控制台输入 npm run dev ,1 秒后项目就启动了;修改了一个文件,保存,50ms 页面就更新了。

Vite是怎么做到的? 它不是魔法,而是巧妙地利用了现代浏览器的原生能力。本文将从最基础的概念讲起,带领我们一步步理解 Vite 的核心原理。

为什么传统构建工具这么慢?

Webpack的工作方式

Webpack 就像我们去参加宴席,必须要等酒店把所有的菜品都准备好,再一次性全部端上来;如果有一道菜没做好,我们就全部得等着:

Webpack的打包过程:
1. 找到入口文件 (main.js)
2. 解析import语句,找出所有依赖
3. 递归解析所有依赖的依赖
4. 把所有文件打包成一个bundle.js
5. 启动开发服务器
6. 浏览器加载bundle.js

随着项目越大,依赖越多,打包就会越慢。

为什么Webpack会越来越慢?

假如我们有这样一个项目结构:

project
├── vue (100个文件)
├── vue-router (50个文件)
├── pinia (30个文件)
├── element-plus (500个文件)
├── 你自己的组件 (200个文件)
└── 各种第三方库 (300个文件)

Webpack 启动时要处理 1180 个文件,并全部打包成一个文件,才能启动开发服务器。

ESM 基础:现代浏览器的模块系统

什么是ES Module?

在 ES Module 出现之前,我们是这样引入 JavaScript 的:

<!-- 老方式:必须按顺序,否则报错 -->
<script src="jquery.js"></script>
<script src="lodash.js"></script>
<script src="app.js"></script>

有了 ES Module 之后,我们可以这样写:

<script type="module">
  // 浏览器会自动加载这些依赖
  import $ from 'https://unpkg.com/jquery'
  import _ from 'https://unpkg.com/lodash'
  import app from './app.js'
</script>

浏览器如何加载ES Module?

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

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

当浏览器遇到这个脚本时,会进行以下操作:

第1步:下载 main.js
     ↓
第2步:解析 main.js,发现需要 vue、App.vue、router
     ↓
第3步:同时下载 vue、App.vue、router (并行下载)
     ↓
第4步:解析 router.js,发现新的依赖
     ↓
第5步:继续下载新的依赖
     ↓
直到所有依赖都加载完成

而且,浏览器可以并行下载多个文件,互不影响。

ESM的核心特性

特性1:静态导入(编译时确定依赖)

import { ref } from 'vue'  // 打包工具可以静态分析

特性2:动态导入(运行时加载)

if (user.isAdmin) {
  const adminPanel = await import('./AdminPanel.vue')
  // 只有在需要时才加载
}

特性3:模块作用域

// a.js
const name = 'module-a'
export { name }

// b.js
const name = 'module-b'  // 同名变量,互不干扰
export { name }

Vite 的核心思想 - 让浏览器做它擅长的事

Vite 的开发服务器

Vite 的开发服务器做了什么?

// 简化的Vite服务器
class ViteDevServer {
  constructor() {
    this.app = require('koa')()  // HTTP服务器
    this.watcher = require('chokidar').watch('src')  // 文件监听
  }
  
  async start() {
    // 1. 启动HTTP服务器
    this.app.listen(3000)
    
    // 2. 注册中间件
    this.app.use(this.transformMiddleware())
    
    // 3. 开始监听文件变化
    this.watcher.on('change', this.handleFileChange.bind(this))
  }
  
  // 处理文件请求
  async transformMiddleware(ctx, next) {
    if (ctx.path.endsWith('.vue')) {
      // 当浏览器请求 .vue 文件时,才进行编译
      const code = await compileVueFile(ctx.path)
      ctx.body = code
    }
  }
}

Vite的启动流程

传统方式(Webpack):
启动 → 打包所有文件 → 启动服务器 → 浏览器请求 → 返回打包后的文件

Vite方式:
启动 → 启动服务器 → 浏览器请求 → 按需编译 → 返回单个文件

还是用餐厅来比喻:

  • Webpack:客人来之前做好所有菜;如果菜没做好,所有客人都得等着
  • Vite:客人点一道,做一道;做好一道,上一道

一个完整的请求流程

假设我们的项目结构是这样的:

src/
├── main.js
├── App.vue
└── components/
    └── HelloWorld.vue

浏览器访问页面的过程如下:

// 第1步:浏览器请求 index.html
GET /index.html

// index.html 内容
<!DOCTYPE html>
<html>
  <head>
    <script type="module" src="/src/main.js"></script>
  </head>
</html>

// 第2步:浏览器发现需要 main.js
GET /src/main.js

// main.js 内容
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

// 第3步:浏览器发现需要 vue 和 App.vue
GET /@modules/vue  // Vite 特殊处理
GET /src/App.vue

// 第4步:App.vue 中又引用了 HelloWorld.vue
GET /src/components/HelloWorld.vue

// 第5步:全部加载完成,页面显示

依赖预构建 - 解决性能瓶颈

如果没有预构建,会有什么问题?

问题1:CommonJS 模块无法在浏览器直接运行

import _ from 'lodash'  // lodash 是 CommonJS 格式,浏览器不认识

问题2:大量小文件请求

import { debounce } from 'lodash-es'
// lodash-es 有 600 多个文件!
// 浏览器要发 600 多个请求!

问题3:深度嵌套的依赖

import A from 'package-a'
// package-a 依赖 package-b
// package-b 依赖 package-c
// 每个包都要单独请求

预构建做了什么?

  1. 扫描项目中的所有 import
  2. 找出第三方依赖(不是相对路径的)
  3. esbuild 打包成单个文件
  4. 存到 node_modules/.vite/
  5. 下次直接使用打包后的文件

esbuild 为什么这么快?

  1. 用 Go 语言写的(直接编译成机器码)
  2. 充分利用 CPU 多核
  3. 一切从零设计,没有历史包袱
  4. 高度并行化

热更新 - 瞬间响应的秘密

热更新模式

修改代码 → 页面自动更新 → 状态保持不变 → 继续工作

热更新的工作原理

我们修改了一个文件
    ↓
Vite 监听到文件变化
    ↓
重新编译这个文件
    ↓
通过 WebSocket 通知浏览器
    ↓
浏览器请求更新的文件
    ↓
执行热更新回调
    ↓
页面局部更新,状态保留

WebSocket 通信

// 服务器端
class HMRServer {
  constructor(server) {
    // 创建 WebSocket 服务
    this.ws = new WebSocket.Server({ server })
    
    // 所有连接的客户端
    this.clients = new Set()
    
    this.ws.on('connection', (socket) => {
      this.clients.add(socket)
      
      socket.on('close', () => {
        this.clients.delete(socket)
      })
    })
  }
  
  // 文件变化时通知所有客户端
  sendUpdate(file) {
    const message = JSON.stringify({
      type: 'update',
      file: file,
      timestamp: Date.now()
    })
    
    this.clients.forEach(client => {
      client.send(message)
    })
  }
}

// 浏览器端
const socket = new WebSocket(`ws://${location.host}`)

socket.onmessage = async ({ data }) => {
  const { type, file, timestamp } = JSON.parse(data)
  
  if (type === 'update') {
    // 重新加载修改的文件
    const module = await import(`${file}?t=${timestamp}`)
    
    // 执行热更新
    if (import.meta.hot) {
      import.meta.hot.accept(file, module)
    }
  }
}

Vue 组件的热更新

// Vue 组件的热更新实现
if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // 更新组件
    const { render, data } = newModule
    
    // 保留当前组件的状态
    const oldData = instance.data
    
    // 应用新的渲染函数
    instance.render = render
    
    // 重新渲染
    instance.update()
  })
}

插件系统:Vite 的扩展能力

插件的工作流程

请求进入
    ↓
resolveId(解析模块 ID)
    ↓
load(加载模块内容)
    ↓
transform(转换代码)
    ↓
返回给浏览器

插件的钩子函数

// 一个完整的 Vite 插件
const myPlugin = {
  name: 'vite:my-plugin',
  
  // 构建阶段钩子
  options(options) {
    // 修改或扩展配置
    return options
  },
  
  buildStart() {
    // 构建开始时调用
    console.log('构建开始')
  },
  
  // 解析模块 ID
  resolveId(source, importer) {
    if (source === 'virtual-module') {
      return '\0virtual-module' // \0 标记为虚拟模块
    }
  },
  
  // 加载模块
  load(id) {
    if (id === '\0virtual-module') {
      return 'export default "virtual module content"'
    }
  },
  
  // 转换代码
  async transform(code, id) {
    if (id.endsWith('.special')) {
      // 转换特殊文件格式
      const result = await compileSpecial(code)
      return {
        code: result.js,
        map: result.sourcemap
      }
    }
  },
  
  // 配置解析完成后
  configResolved(config) {
    console.log('配置已解析', config)
  },
  
  // 热更新处理
  handleHotUpdate(ctx) {
    // 自定义热更新逻辑
  },
  
  // 构建结束
  buildEnd() {
    console.log('构建结束')
  },
  
  // 关闭服务
  closeBundle() {
    console.log('服务关闭')
  }
}

常用插件示例

// 环境变量注入插件
function injectEnvPlugin(env: Record<string, string>) {
  return {
    name: 'vite:inject-env',
    
    transform(code, id) {
      if (id.includes('node_modules')) return
      
      // 替换环境变量
      return code.replace(
        /import\.meta\.env\.(\w+)/g,
        (_, key) => JSON.stringify(env[key])
      )
    }
  }
}

// 文件大小监控插件
function sizeMonitorPlugin() {
  return {
    name: 'vite:size-monitor',
    
    generateBundle(_, bundle) {
      Object.entries(bundle).forEach(([name, asset]) => {
        if (asset.type === 'chunk') {
          const size = asset.code.length
          const kb = (size / 1024).toFixed(2)
          
          if (size > 100 * 1024) {
            console.warn(`⚠️ 大文件警告: ${name} (${kb}KB)`)
          } else {
            console.log(`✅ ${name}: ${kb}KB`)
          }
        }
      })
    }
  }
}

Vite vs Webpack

启动时间对比

项目规模 Webpack Vite 差距
小项目(50组件) 8.5秒 1.2秒 Vite快7倍
中项目(200组件) 22秒 2.1秒 Vite快10倍
大项目(1000组件) 58秒 3.8秒 Vite快15倍

热更新时间对比

操作 Webpack Vite 差距
修改一个组件 2.8秒 45ms Vite快62倍
修改CSS 1.5秒 8ms Vite快187倍
保存后恢复 3.1秒 60ms Vite快52倍

资源消耗对比

指标 Webpack Vite 差距
CPU占用 45% 18% 降低60%
内存占用 1.8GB 420MB 降低77%
电池消耗 延长2-3倍

常见问题与优化技巧

问题一:依赖预构建失效

修改了 node_modules 里的代码,但是不生效:

解决方案1:强制重新预构建

// vite.config.ts
export default {
  optimizeDeps: {
    // 强制重新预构建
    force: true
  }
}

解决方案2:删除缓存目录

$ rm -rf node_modules/.vite

解决方案3:重启开发服务器

npm run dev

问题二:热更新不生效

修改了文件,但页面不更新,可以按以下步骤排查:

步骤1:检查 WebSocket 连接

打开浏览器控制台,看是否有 WebSocket 连接。

步骤2:检查文件监听配置

export default {
  server: {
    watch: {
      // 确保没有忽略我们的文件
      ignored: ['!**/node_modules/**']
    }
  }
}

步骤3:手动触发更新

if (import.meta.hot) {
  import.meta.hot.accept()
}

问题三:首次加载慢

第一次打开页面要等很久。

解决方案:预加载关键路由

export default {
  optimizeDeps: {
    include: [
      // 预构建这些依赖
      'vue',
      'vue-router',
      'pinia',
      // 你的常用组件
      'src/components/Button.vue',
      'src/components/Modal.vue'
    ]
  }
}

问题四:内存占用过高

// vite.config.ts
export default {
  server: {
    // 限制缓存大小
    moduleCache: {
      maxSize: 100 * 1024 * 1024 // 100MB
    },
    
    // 清理未使用的模块
    moduleGraph: {
      pruneInterval: 60000 // 每 60 秒清理一次
    }
  }
}

Vite 的最佳实践

Vite 配置文件模板

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

export default defineConfig({
  // 插件
  plugins: [vue()],
  
  // 开发服务器配置
  server: {
    port: 3000,
    open: true,  // 自动打开浏览器
    proxy: {
      '/api': 'http://localhost:8080'  // 代理
    }
  },
  
  // 构建配置
  build: {
    target: 'es2020',
    outDir: 'dist',
    assetsDir: 'assets',
    sourcemap: true
  },
  
  // 依赖优化
  optimizeDeps: {
    include: ['vue', 'vue-router', 'pinia']
  },
  
  // 别名
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  }
})

性能优化清单

  • 依赖预构建:配置 optimizeDeps.include 预构建常用依赖
  • 路由懒加载:使用动态 import() 分割代码
  • 图片优化:使用 vite-plugin-image-optimizer
  • CSS 提取:生产环境提取独立 CSS 文件
  • Gzip 压缩:使用 vite-plugin-compression

学习要点

  1. 理解 ESM 的核心特性:静态导入、模块作用域、浏览器加载机制
  2. 掌握依赖预构建的作用:解决 CommonJS 兼容性、减少请求数
  3. 熟悉热更新的工作流程:WebSocket 通信、模块边界、HMR API
  4. 学会编写 Vite 插件:钩子函数、虚拟模块、代码转换
  5. 能够诊断和优化性能问题:预构建失效、热更新慢、内存占用高

结语

Vite 的出现,标志着前端构建工具从打包时代进入了原生 ESM 时代。理解它的核心原理,不仅能让我们更高效地使用它,更能让我们对现代前端开发有更深的理解。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Cesium 海量点位不卡顿!图标动态聚合效果深度解析,看完直接抄代码!

作者 李剑一
2026年3月19日 09:45

接上文# 告别冗余代码!Cesium点位图标模糊、重叠?自适应参数调优攻略,一次封装终身复用!,在地图上创建图标是基础操作,但是当地图上的图标过多的时候展示效果其实并不好。

毕竟谁也不想看到密密麻麻的图标,所以部分距离相近的图标应该聚合在一起,形成一个聚合图标展示出来。

image.png

在Cesium开发中,图标聚合能够解决海量图标重叠、界面杂乱、性能卡顿等问题。

尤其在智慧安防、智慧园区、设备监控等场景,几十个甚至上百个摄像头/设备图标挤在一块,不仅看不清,还会严重影响地图流畅度。

解决方案

通过监听相机高度,高度超过阈值,自动开启聚合。

根据计算屏幕像素距离,把三维坐标转成屏幕坐标,算两点多远,距离小于设定值,归为一组。

image.png

这时候隐藏原始图标,只显示聚合图标。

生成聚合点:显示图标+数量,拉近后自动散开。

实现代码

计算屏幕距离 + 判断是否在屏幕内。是聚合的核心基础:把三维坐标转屏幕坐标,再算距离。

/**
 * 计算两点在屏幕上的像素距离
 */
const calculateScreenDistance = (pos1, pos2) => {
    if (!viewer.value || !viewer.value.scene) return Infinity
    
    const scene = viewer.value.scene
    try {
        // 世界坐标 → 屏幕坐标
        const screenPos1 = Cesium.SceneTransforms.worldToWindowCoordinates(scene, pos1)
        const screenPos2 = Cesium.SceneTransforms.worldToWindowCoordinates(scene, pos2)
        
        if (!screenPos1 || !screenPos2) return Infinity
        
        // 勾股定理算像素距离
        const dx = screenPos1.x - screenPos2.x
        const dy = screenPos1.y - screenPos2.y
        return Math.sqrt(dx * dx + dy * dy)
    } catch (error) {
        return Infinity
    }
}

/**
 * 检查点是否在屏幕上可见
 */
const isPositionOnScreen = (position) => {
    if (!viewer.value || !viewer.value.scene) return false
    try {
        const screenPos = Cesium.SceneTransforms.worldToWindowCoordinates(viewer.value.scene, position)
        return screenPos != null
    } catch (error) {
        return false
    }
}

生成聚合点,图标更大、创建label显示当前标签数量更明显。

/**
 * 创建聚合图标
 */
const createClusterIcon = (clusterData) => {
    if (!viewer.value) return null
    const { icons, type, center } = clusterData
    const count = icons.length

    // 坐标转换
    const cartographic = Cesium.Cartographic.fromCartesian(center)
    const longitude = Cesium.Math.toDegrees(cartographic.longitude)
    const latitude = Cesium.Math.toDegrees(cartographic.latitude)

    // 创建聚合实体
    const clusterId = `cluster_${type}_${Date.now()}`
    const entity = viewer.value.entities.add({
        id: clusterId,
        position: center,
        billboard: {
            image: getClusterIconUrl(type),
            scale: 1.2,
            width: 40,
            height: 40,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            disableDepthTestDistance: Number.POSITIVE_INFINITY
        }
    })

    // 聚合数量标签
    const typeName = getTypeDisplayName(type)
    entity.label = {
        text: `${typeName} ${count}个`,
        font: '14px sans-serif',
        fillColor: Cesium.Color.WHITE,
        outlineColor: Cesium.Color.BLACK,
        outlineWidth: 2,
        pixelOffset: new Cesium.Cartesian2(0, -50),
        showBackground: true,
        disableDepthTestDistance: Number.POSITIVE_INFINITY
    }

    // 存入聚合列表
    clusterEntities.set(clusterId, { entity, icons, type, center })
    return entity
}

动态计算聚合阈值,通过遍历图标 → 分组 → 合并/显示,自动隐藏原始图标,显示聚合点。

/**
 * 更新图标聚合状态
 */
const updateClustering = () => {
    if (!viewer.value || iconEntities.size === 0) return
    clearClusters()

    // 关闭聚合 = 显示全部
    if (!isClusteringEnabled.value) {
        showAllIcons()
        return
    }

    // 动态阈值:相机越高,聚合越明显
    const cameraHeight = viewer.value.camera.positionCartographic.height
    const dynamicClusterDistance = Math.min(
        MAX_SCREEN_CLUSTER_DISTANCE,
        SCREEN_CLUSTER_DISTANCE + (cameraHeight - CLUSTER_THRESHOLD) / 50
    )

    // 收集所有图标
    const allIcons = []
    iconEntities.forEach((iconData, id) => {
        const position = iconData.entity.position.getValue(Cesium.JulianDate.now())
        allIcons.push({ id, entity: iconData.entity, position, type: iconData.type })
    })

    // 先隐藏所有图标
    allIcons.forEach(icon => icon.entity.show = false)

    // 聚类算法
    const clusters = []
    const visited = new Set()

    for (let i = 0; i < allIcons.length; i++) {
        if (visited.has(i)) continue
        const current = allIcons[i]
        if (!isPositionOnScreen(current.position)) continue

        const cluster = [current]
        visited.add(i)

        // 寻找附近图标
        for (let j = i + 1; j < allIcons.length; j++) {
            if (visited.has(j)) continue
            const other = allIcons[j]
            if (!isPositionOnScreen(other.position)) continue

            const dist = calculateScreenDistance(current.position, other.position)
            if (dist <= dynamicClusterDistance) {
                cluster.push(other)
                visited.add(j)
            }
        }
        clusters.push(cluster)
    }

    // 生成聚合点 / 显示单个图标
    clusters.forEach(cluster => {
        if (cluster.length === 1) {
            cluster[0].entity.show = true
        } else {
            // 计算中心点
            let centerX = 0, centerY = 0, centerZ = 0
            cluster.forEach(icon => {
                centerX += icon.position.x
                centerY += icon.position.y
                centerZ += icon.position.z
            })
            const center = new Cesium.Cartesian3(
                centerX / cluster.length,
                centerY / cluster.length,
                centerZ / cluster.length
            )

            createClusterIcon({
                icons: cluster.map(c => c.id),
                type: 'camera',
                center
            })
        }
    })
}

总结

Cesium 图标聚合原理上很简单:

算距离 → 分组 → 隐藏/显示 → 生成聚合点

在园区级别的模型上其实启不启用影响不大,但是在城市级别,或者是多地区复杂情况的模型上还是有必要的。

能够极大的提升加载的流畅度,减少操作的卡顿。

生产环境极致优化:拆包、图片压缩、Gzip/Brotli 完全指南

作者 wuhen_n
2026年3月20日 10:53

前言

当我们的应用从开发环境走向生产环境,真正的挑战才刚刚开始。用户不会关心我们的代码写得多么优雅,他们只关心页面加载快不快、交互流不流畅。一个未经优化的生产构建,可能让我们的用户在第一秒就流失。

为什么要优化生产构建?

一个真实的反面教材

我们先来看一个系统打包后的产物:

dist/
├── index.html                5KB
├── assets/index.abc123.js    2.8MB  ← 一个文件包含了所有代码
├── assets/vendor.def456.js   1.2MB  ← 第三方库
├── assets/style.ghi789.css   180KB
└── images/
    ├── logo.png              120KB  ← 未压缩
    ├── banner.jpg            850KB  ← 巨大
    └── ...

当用户访问这个系统时:

  • 下载 2.8MB + 1.2MB + 180KB + 970KB = 约 5MB
  • 4G 网络下需要 2 秒;3G 网络会更慢
  • 用户早跑了

构建优化的核心目标

优化维度 目标 收益
拆包优化 分离业务代码和第三方库 利用浏览器缓存,二次访问提速
图片压缩 减少图片体积 平均减少 60-80% 体积
Gzip/Brotli 压缩文本资源 减少 70-90% 传输体积
长期缓存 文件名哈希,内容变化才更新 最大化缓存利用率

优化能带来什么?

指标 优化前 优化后 提升
首屏 JS 体积 4.2 MB 2.1 MB 50%
图片总体积 2.8 MB 0.6 MB 78%
传输体积(Gzip后) 3.2 MB 0.8 MB 75%
首次加载时间 3.2 秒 1.1 秒 65%
二次加载时间 2.1 秒 0.3 秒 85%

先诊断,后开药 - 构建分析工具

为什么要先分析?

就像医生看病要先做检查一样,优化构建也要先找到问题在哪。在主观上,我们可能会觉得是不是某个依赖太大了?但实际上可能是另一个我们没想到的库!

使用 rollup-plugin-visualizer 分析

安装

npm install --save-dev rollup-plugin-visualizer

配置

// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default {
  plugins: [
    visualizer({
      filename: 'dist/stats.html',  // 输出文件
      open: true,                   // 构建后自动打开
      gzipSize: true,                // 显示 gzip 后大小
      brotliSize: true,              // 显示 brotli 后大小
      template: 'treemap'            // 图表类型: treemap, sunburst, network
    })
  ]
}

运行构建

npm run build
// 浏览器会自动打开一个酷炫的图表
// 一眼就能看出哪些文件最大

使用 vite-bundle-visualizer 分析

安装

npm install --save-dev vite-bundle-visualizer

运行分析

npx vite-bundle-visualizer

输出示例

┌───────────────────────┬─────────────┬──────────┬───────┐
│       Module          │    Size     │  Gzip    │ Brotli│
├───────────────────────┼─────────────┼──────────┼───────┤
│ node_modules/         │ 2.3 MB      │ 680 KB   │ 520 KB│
│   vue/                │ 680 KB      │ 210 KB   │ 160 KB│
│   element-plus/       │ 890 KB      │ 280 KB   │ 210 KB│
│   echarts/            │ 520 KB      │ 150 KB   │ 115 KB│
│   lodash-es/          │ 210 KB      │ 62 KB    │ 48 KB │
│ src/                  │ 1.8 MB      │ 480 KB   │ 360 KB│
└───────────────────────┴─────────────┴──────────┴───────┘

自定义分析脚本

// scripts/analyze.js
import fs from 'fs'
import path from 'path'
import { gzipSizeSync } from 'gzip-size'
import { brotliSizeSync } from 'brotli-size'

function analyzeDist() {
  const distDir = path.resolve('./dist/assets')
  const files = fs.readdirSync(distDir)
  
  let totalSize = 0
  let totalGzip = 0
  let totalBrotli = 0
  
  console.log('📦 构建产物分析\n')
  
  files
    .filter(f => f.endsWith('.js') || f.endsWith('.css'))
    .forEach(file => {
      const filePath = path.join(distDir, file)
      const content = fs.readFileSync(filePath)
      const size = content.length
      const gzip = gzipSizeSync(content)
      const brotli = brotliSizeSync(content)
      
      totalSize += size
      totalGzip += gzip
      totalBrotli += brotli
      
      console.log(`${file}:`)
      console.log(`  Raw:    ${(size / 1024).toFixed(2)} KB`)
      console.log(`  Gzip:   ${(gzip / 1024).toFixed(2)} KB (${(gzip/size*100).toFixed(0)}%)`)
      console.log(`  Brotli: ${(brotli / 1024).toFixed(2)} KB (${(brotli/size*100).toFixed(0)}%)\n`)
    })
  
  console.log('📊 总计:')
  console.log(`  Raw:    ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
  console.log(`  Gzip:   ${(totalGzip / 1024 / 1024).toFixed(2)} MB`)
  console.log(`  Brotli: ${(totalBrotli / 1024 / 1024).toFixed(2)} MB`)
}

analyzeDist()

看懂分析结果

分析结果能告诉我们什么?

1. 找出最大的依赖

  • echarts: 520KB → 考虑按需加载
  • monaco-editor: 2.8MB → 考虑动态导入

2. 找出重复的依赖

  • lodash 和 lodash-es 同时存在? → 统一用 lodash-es
  • moment 和 dayjs 同时存在? → 用 dayjs 替代 moment

3. 找出可以拆分的点

  • node_modules 打包在一起太大了 → 拆成多个 chunk
  • 所有页面代码都在一个文件里 → 按路由拆分

拆包策略 - 把大象放进冰箱

为什么要拆包?

用一个比喻来解释

不拆包:把所有东西都塞进一个行李箱
├─ 想拿牙刷 → 要翻遍整个箱子
├─ 箱子破了 → 所有东西都掉出来
└─ 箱子太大 → 搬不动

拆包:分成多个小包
├─ 洗漱包:牙刷、牙膏、毛巾
├─ 衣物包:衣服、裤子、袜子
├─ 电子包:充电器、数据线
├─ 哪个包破了 → 只损失那部分
└─ 每个包都很轻 → 好搬

技术层面的好处

不拆包:
├─ 修改一行代码 → 整个大文件缓存失效
└─ 用户每次更新都要重新下载所有代码

拆包后:
├─ 第三方库独立 → 几乎不变,长期缓存
├─ 业务代码拆分 → 只下载修改的部分
└─ 多个小文件可以并行下载

基础拆包配置

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 最基本的拆包策略
        manualChunks: {
          // 将 Vue 全家桶打包在一起
          'vendor-vue': ['vue', 'vue-router', 'pinia', 'vuex'],
          
          // 将 UI 库打包在一起
          'vendor-ui': ['element-plus', '@element-plus/icons-vue', 'ant-design-vue'],
          
          // 将工具库打包在一起
          'vendor-utils': ['lodash-es', 'dayjs', 'axios', 'date-fns'],
          
          // 将图表库打包在一起
          'vendor-charts': ['echarts', 'd3', 'chart.js']
        }
      }
    }
  }
}

智能拆包:根据依赖关系自动拆分

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id: string) {
          // node_modules 中的依赖
          if (id.includes('node_modules')) {
            // 按包名拆分
            if (id.includes('vue')) {
              return 'vendor-vue'  // 所有 vue 相关
            }
            
            if (id.includes('element-plus') || id.includes('antd')) {
              return 'vendor-ui'   // UI 库
            }
            
            if (id.includes('echarts') || id.includes('d3')) {
              return 'vendor-charts' // 图表库
            }
            
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'  // 工具库
            }
            
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'  // 编辑器单独打包
            }
            
            // 其他依赖打包在一起
            return 'vendor-other'
          }
          
          // 业务代码按页面拆分
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) {
              return `page-${match[1]}` // 按页面拆分
            }
          }
          
          // 公共组件按模块拆分
          if (id.includes('/src/components/')) {
            const match = id.match(/\/src\/components\/([^\/]+)/)
            if (match) {
              return `components-${match[1]}`
            }
          }
        }
      }
    }
  }
}

高级拆包:基于大小的自动拆分

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id: string, { getModuleInfo }) {
          // 如果模块大于 500KB,单独拆包
          const moduleInfo = getModuleInfo(id)
          if (moduleInfo && moduleInfo.code) {
            const size = Buffer.byteLength(moduleInfo.code, 'utf8')
            if (size > 500 * 1024) { // 500KB
              const name = id.match(/[^/]+\.(js|ts|vue)$/)?.[0]
              return `large-${name}`  // 大文件单独打包
            }
          }
          
          // 继续其他拆分逻辑
          if (id.includes('node_modules')) {
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus')) return 'vendor-ui'
          }
        }
      }
    }
  }
}

异步 chunk 的命名优化

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 异步 chunk 命名
        chunkFileNames: 'assets/chunks/[name]-[hash].js',
        
        // 入口文件命名
        entryFileNames: 'assets/[name]-[hash].js',
        
        // 资源文件命名
        assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
        
        manualChunks: {
          // ... 拆包配置
        }
      }
    }
  }
}

// 输出结果:
// assets/index-abc123.js                (入口)
// assets/chunks/vendor-vue-def456.js    (Vue 相关)
// assets/chunks/page-dashboard-ghi789.js (页面)
// assets/images/logo-jkl012.png         (图片)

拆包后的效果

拆包方式 文件数量 缓存利用率 适用场景
不拆包 1个 极低 小项目
按依赖拆分 5-10个 中大型项目
按页面拆分 10-50个 较高 多页面应用
按大小拆分 可变 中等 有大文件的项目

图片压缩 - 看不见的优化

为什么图片是优化重点?

我们先来看一个典型的页面资源分布:

const pageResources = {
  js: '2.8MB (40%)',
  css: '180KB (3%)',
  images: '3.5MB (50%)',  // 图片占了一半!
  fonts: '500KB (7%)'
}

在页面中,图片通常占页面总体积的 50-70%,因此优化图片是最容易见效的!

vite-plugin-image-optimizer 配置

安装

npm install --save-dev vite-plugin-image-optimizer

配置

// vite.config.ts
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'

export default {
  plugins: [
    ViteImageOptimizer({
      // 配置文件类型和压缩参数
      png: {
        quality: 80,  // PNG 质量 0-100
        compressionLevel: 9, // 压缩级别 0-9
      },
      jpeg: {
        quality: 75,  // JPEG 质量
        progressive: true, // 渐进式 JPEG
      },
      jpg: {
        quality: 75,
      },
      webp: {
        quality: 75,  // WebP 质量
        lossless: false, // 是否无损
      },
      avif: {
        quality: 60,  // AVIF 质量
        lossless: false,
      },
      svg: {
        // SVG 优化选项
        plugins: [
          {
            name: 'preset-default',
            params: {
              overrides: {
                removeViewBox: false, // 保留 viewBox
                cleanupIds: false,     // 保留 ID
              },
            },
          },
        ],
      },
      tiff: {
        quality: 70,
      },
      gif: {
        optimizationLevel: 3, // 优化级别 1-3
      },
    })
  ]
}

不同图片类型的优化策略

// vite.config.ts
export default {
  plugins: [
    ViteImageOptimizer({
      // 根据不同用途设置不同参数
      
      // 1. 图标类:需要清晰,适当压缩
      'src/assets/icons/**/*': {
        png: { quality: 90 },
        svg: { plugins: ['preset-default'] }
      },
      
      // 2. 背景图:可以牺牲一些质量换取体积
      'src/assets/backgrounds/**/*': {
        jpeg: { quality: 65 },
        webp: { quality: 60 }
      },
      
      // 3. 产品图:平衡质量和体积
      'src/assets/products/**/*': {
        jpeg: { quality: 80 },
        webp: { quality: 75 }
      },
      
      // 4. 用户上传:保持较好质量
      'src/assets/uploads/**/*': {
        jpeg: { quality: 85 },
        png: { quality: 85 }
      }
    })
  ]
}

使用现代图片格式

配置

// vite.config.ts
export default {
  plugins: [
    ViteImageOptimizer({
      // 生成 WebP 版本(浏览器支持更好)
      webp: {
        quality: 75
      },
      
      // 生成 AVIF 版本(压缩率更高)
      avif: {
        quality: 60
      }
    })
  ]
}

在组件中配合使用

<template>
  <!-- picture 元素让浏览器选择最佳格式 -->
  <picture>
    <!-- 现代浏览器优先使用 AVIF -->
    <source srcset="/image.avif" type="image/avif">
    <!-- 其次使用 WebP -->
    <source srcset="/image.webp" type="image/webp">
    <!-- 降级到 JPEG -->
    <img src="/image.jpg" alt="图片" loading="lazy">
  </picture>
</template>

懒加载与图片优化结合

<template>
  <img 
    v-lazy="optimizedImageUrl"
    :data-srcset="`
      ${smallImage} 400w,
      ${mediumImage} 800w,
      ${largeImage} 1200w
    `"
    sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
    loading="lazy"
    :alt="alt"
  >
</template>

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

const props = defineProps<{ 
  imagePath: string,
  alt?: string 
}>()

// 根据视图宽度选择合适大小的图片
const optimizedImageUrl = computed(() => {
  // 假设构建时生成了不同尺寸的图片
  // logo-small.jpg, logo-medium.jpg, logo-large.jpg
  const width = typeof window !== 'undefined' ? window.innerWidth : 1200
  
  if (width < 600) {
    return props.imagePath.replace(/\.(jpg|png)$/, '-small.$1')
  }
  if (width < 1200) {
    return props.imagePath.replace(/\.(jpg|png)$/, '-medium.$1')
  }
  return props.imagePath.replace(/\.(jpg|png)$/, '-large.$1')
})
</script>

图片优化的效果

图片类型 优化前 优化后 节省
PNG 图标 120KB 35KB 71%
JPG 产品图 850KB 180KB 79%
WebP 背景 650KB 110KB 83%
SVG 矢量 15KB 8KB 47%
总体积 2.8MB 0.6MB 78%

Gzip/Brotli 压缩 - 让传输更轻盈

什么是 Gzip/Brotli?

我们可以用快递来比喻,比如我们有一件很大的“羽绒服”要邮寄给浏览器:

  • 原始文件:一件羽绒服(很大,但很轻)
  • Gzip:真空压缩袋,把羽绒服压扁
  • Brotli:更好的真空压缩袋,压得更扁

当浏览器收到压缩后的文件,它只需要打开压缩袋,羽绒服(文件)就可以恢复原状!

压缩算法的对比

算法 压缩率 压缩速度 解压速度 浏览器支持
Gzip 中等 所有浏览器
Brotli 中等 现代浏览器 (92%)
Deflate 极快 极快 所有浏览器

相同文件对比

  • 原始 JS: 1000 KB
  • Gzip: 280 KB (72% 减少)
  • Brotli: 220 KB (78% 减少)
  • Brotli 比 Gzip 再减少 21% 体积

使用 vite-plugin-compression 配置

安装

npm install --save-dev vite-plugin-compression

配置

// vite.config.ts
import compression from 'vite-plugin-compression'

export default {
  plugins: [
    // Gzip 压缩
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      threshold: 10240, // 10KB 以上才压缩
      deleteOriginFile: false, // 保留原文件
      verbose: true, // 输出压缩信息
      filter: /\.(js|css|html|svg)$/ // 只压缩文本文件
    }),
    
    // Brotli 压缩
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 10240,
      deleteOriginFile: false,
      verbose: true,
      filter: /\.(js|css|html|svg)$/
    })
  ]
}

// 构建结果:
// index.abc123.js
// index.abc123.js.gz    (Gzip)
// index.abc123.js.br    (Brotli)

智能压缩策略 - 多算法混合策略

// vite.config.ts
import compression from 'vite-plugin-compression'

export default {
  plugins: [
    // 对不同的资源使用不同的策略
    
    // 1. HTML: 使用 Brotli(最高压缩率)
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.html$/,
      threshold: 1024
    }),
    
    // 2. JS/CSS: 同时生成 Gzip 和 Brotli
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      filter: /\.(js|css)$/,
      threshold: 10240
    }),
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.(js|css)$/,
      threshold: 10240
    }),
    
    // 3. 大文件用 Brotli,小文件用 Gzip
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.(js|css)$/,
      threshold: 51200 // 50KB 以上用 Brotli
    }),
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      filter: /\.(js|css)$/,
      threshold: 10240, // 10-50KB 用 Gzip
      deleteOriginFile: true // 小文件可以删除原文件
    })
  ]
}

Nginx 配置示例

# nginx.conf
server {
  listen 80;
  server_name example.com;
  root /usr/share/nginx/html;
  
  # 开启 Gzip
  gzip on;
  gzip_vary on;
  gzip_min_length 10240;
  gzip_types text/plain text/css text/xml text/javascript 
             application/javascript application/x-javascript 
             application/xml application/json;
  gzip_comp_level 6;
  gzip_buffers 16 8k;
  gzip_http_version 1.1;
  
  # Brotli 支持(需要编译 brotli 模块)
  brotli on;
  brotli_min_length 10240;
  brotli_types text/plain text/css text/xml text/javascript 
               application/javascript application/x-javascript 
               application/xml application/json;
  brotli_comp_level 6;
  
  location / {
    try_files $uri $uri/ /index.html;
    
    # 尝试 Brotli,然后是 Gzip,最后是原始文件
    location ~* \.(js|css)$ {
      try_files $uri.br $uri.gz $uri =404;
      
      # 根据 Accept-Encoding 设置正确的 Content-Encoding
      if ($http_accept_encoding ~* br) {
        add_header Content-Encoding br;
        add_header Content-Type $content_type;
      }
      if ($http_accept_encoding ~* gzip) {
        add_header Content-Encoding gzip;
        add_header Content-Type $content_type;
      }
      
      # 长期缓存
      expires 1y;
      add_header Cache-Control "public, immutable";
      add_header Vary Accept-Encoding;
    }
    
    # 图片缓存
    location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
      expires 30d;
      add_header Cache-Control "public";
    }
  }
}

验证压缩效果

# 使用 curl 验证压缩

# 查看是否支持压缩
curl -H "Accept-Encoding: gzip, br" -I https://example.com/app.js

# 响应头应该包含
Content-Encoding: br
Content-Type: application/javascript
Content-Length: 220000

# 下载并解压验证
curl -H "Accept-Encoding: br" https://example.com/app.js | brotli -d

# 或者使用 httpie
http https://example.com/app.js Accept-Encoding:br

长期缓存策略:让缓存最大化

文件名哈希的原理

// 构建后的文件名
// index.[hash].js

// 哈希是基于文件内容生成的
// 内容不变 → 哈希不变 → 缓存有效
// 内容变化 → 哈希变化 → 重新下载

dist/
├── index.abc123.js    // 哈希基于内容生成
├── index.def456.js    // 内容变化,哈希变化
├── vendor-vue.123abc.js // 第三方库几乎不变
└── vendor-ui.456def.js   // UI 库偶尔更新

配置文件名哈希

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 入口文件
        entryFileNames: 'assets/[name].[hash].js',
        
        // 异步 chunk
        chunkFileNames: 'assets/chunks/[name].[hash].js',
        
        // 资源文件
        assetFileNames: 'assets/[ext]/[name].[hash].[ext]',
        
        manualChunks: {
          // 稳定的第三方库单独打包(几乎不变)
          'vendor-stable': [
            'vue',
            'vue-router',
            'pinia',
            'vuex'
          ],
          
          // 可能更新的 UI 库单独打包
          'vendor-ui': [
            'element-plus',
            '@element-plus/icons-vue',
            'ant-design-vue'
          ],
          
          // 可能更新的工具库
          'vendor-utils': [
            'lodash-es',
            'dayjs',
            'axios'
          ]
        }
      }
    },
    
    // 生成 manifest.json
    manifest: true
  }
}

Nginx 缓存配置

# nginx.conf
server {
  # 静态资源缓存配置
  
  # JS/CSS 长期缓存(带 hash 的文件)
  location ~* \.(js|css)$ {
    # 匹配带 hash 的文件
    if ($uri ~* "\.[a-f0-9]{8,20}\.(js|css)$") {
      expires 1y;
      add_header Cache-Control "public, immutable";
    }
    
    # 如果不带 hash,短时间缓存
    expires 1h;
    add_header Cache-Control "public";
    
    # 尝试压缩版本
    try_files $uri.br $uri.gz $uri =404;
    add_header Vary Accept-Encoding;
  }
  
  # 图片等资源
  location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
    expires 30d;
    add_header Cache-Control "public";
  }
  
  # 字体文件
  location ~* \.(woff2?|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    add_header Access-Control-Allow-Origin "*";
  }
  
  # HTML 文件不缓存
  location ~* \.html$ {
    expires -1;
    add_header Cache-Control "no-cache, must-revalidate";
  }
}

Service Worker 缓存策略

// sw.js
const CACHE_NAME = 'v1'
const CACHE_URLS = [
  '/',
  '/index.html',
  '/manifest.json'
]

// 安装时缓存核心资源
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(CACHE_URLS))
  )
})

// 缓存策略:缓存优先,网络回退
self.addEventListener('fetch', event => {
  const url = new URL(event.request.url)
  
  // 静态资源使用 Cache First 策略
  if (url.pathname.match(/\.(js|css|png|jpg|webp)$/)) {
    event.respondWith(
      caches.match(event.request)
        .then(response => {
          // 缓存命中直接返回
          if (response) return response
          
          // 未命中则请求网络并缓存
          return fetch(event.request).then(response => {
            const clone = response.clone()
            caches.open(CACHE_NAME).then(cache => {
              cache.put(event.request, clone)
            })
            return response
          })
        })
    )
  } 
  // HTML 使用 Network First 策略
  else if (url.pathname.endsWith('.html') || url.pathname === '/') {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          const clone = response.clone()
          caches.open(CACHE_NAME).then(cache => {
            cache.put(event.request, clone)
          })
          return response
        })
        .catch(() => caches.match(event.request))
    )
  }
})

缓存命中率的提升

文件类型 更新频率 缓存策略 命中率
vendor-vue.js 几乎不变 永久缓存 99%
vendor-ui.js 偶尔更新 永久缓存 92%
page-*.js 经常更新 永久缓存 65%
图片 很少更新 30天缓存 95%
字体 从不更新 永久缓存 99%

实战案例:一个中大型项目的构建优化

优化前的状态

// 项目信息
// - 页面数量:45 个
// - 组件数量:850 个
// - 第三方依赖:230 个
// - 图片数量:1200 张

// 构建产物
dist/ 总大小: 45 MB
├── js/      28 MB
├── css/     2.5 MB
├── images/  14 MB
└── others/  0.5 MB

// 性能指标
// - 构建时间:3 分 45 秒
// - 首屏体积:4.2 MB
// - 加载时间:3.2 秒

优化步骤

第一步:分析找出问题

# 运行分析
npx vite-bundle-visualizer

# 发现问题
echarts: 1.2MB        ← 太大
monaco-editor: 2.8MB  ← 巨大!
lodash-es: 210KB      ← 还好
moment: 450KB         ← 可以用 dayjs 替代

第二步:优化拆包

// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 把 echarts 单独打包
            if (id.includes('echarts')) {
              return 'vendor-echarts'
            }
            
            // 把 monaco-editor 单独打包
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'
            }
            
            // 其他分组
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus')) return 'vendor-ui'
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'
            }
            
            return 'vendor-other'
          }
          
          // 按页面拆分
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) return `page-${match[1]}`
          }
        }
      }
    }
  }
}

第三步:图片压缩

// vite.config.js
export default {
  plugins: [
    ViteImageOptimizer({
      png: { quality: 75 },
      jpeg: { quality: 70 },
      webp: { quality: 70 },
      avif: { quality: 60 }
    })
  ]
}

第四步:开启压缩

// vite.config.js
export default {
  plugins: [
    compression({
      algorithm: 'brotliCompress',
      threshold: 10240
    })
  ]
}

第五步:按需加载

// 大组件使用动态导入
const MonacoEditor = defineAsyncComponent(() => 
  import('monaco-editor')
)

// 路由懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('./views/Dashboard.vue')  // 按需加载
  }
]

优化后的结果

指标 优化前 优化后 提升
构建时间 3 分 45 秒 2 分 20 秒 38%
总大小 45 MB 18 MB 60%
首屏 JS 体积 4.2 MB 1.8 MB 57%
图片体积 14 MB 3.5 MB 75%
传输体积 3.2 MB 0.8 MB 75%
加载时间 3.2 秒 1.1 秒 65%

常见问题与解决方案

问题一:拆包过多导致请求数爆炸

// ❌ 错误:拆得太细
manualChunks(id) {
  // 每个依赖都单独打包
  return id.match(/node_modules\/([^\/]+)/)?.[1]
}
// 结果:产生 200+ 个文件,HTTP/1.1 下性能差

// ✅ 正确:合理分组
manualChunks(id) {
  if (id.includes('node_modules')) {
    if (id.includes('vue')) return 'vendor-vue'
    if (id.includes('lodash')) return 'vendor-utils'
    if (id.includes('echarts')) return 'vendor-charts'
    if (id.includes('monaco')) return 'vendor-monaco'
    return 'vendor-other' // 其他合并
  }
}

问题二:图片压缩后质量下降

// 解决方案:选择性压缩
ViteImageOptimizer({
  // 图标保留较高品质
  'src/assets/icons/**/*': {
    png: { quality: 90 },
    svg: { plugins: ['preset-default'] }
  },
  
  // 背景图可以接受较低品质
  'src/assets/backgrounds/**/*': {
    jpeg: { quality: 65 },
    webp: { quality: 60 }
  },
  
  // 产品图需要平衡
  'src/assets/products/**/*': {
    jpeg: { quality: 80 },
    webp: { quality: 75 }
  }
})

// 或者使用图片 CDN 动态处理
<img src="https://cdn.example.com/image.jpg?x-oss-process=image/resize,w_400/quality,q_80">

问题三:Brotli 压缩太慢

// ✅ 解决方案:选择性使用 Brotli
compression({
  algorithm: 'brotliCompress',
  threshold: 50000,  // 50KB 以上才用 Brotli
  filter: /\.(js|css)$/
})

// 小文件继续用 Gzip
compression({
  algorithm: 'gzip',
  threshold: 10240,  // 10-50KB 用 Gzip
  filter: /\.(js|css)$/
})

问题四:CDN 不支持 Brotli

# ✅ 解决方案:同时生成 Gzip 和 Brotli
location /assets {
    # 优先尝试 Brotli
    try_files $uri.br $uri.gz $uri =404;
    
    # 根据 Accept-Encoding 返回正确的 Content-Encoding
    if ($http_accept_encoding ~* br) {
        add_header Content-Encoding br;
    }
    if ($http_accept_encoding ~* gzip) {
        add_header Content-Encoding gzip;
    }
}

生产环境优化的最佳实践

优化检查清单

  • 使用 visualizer 分析构建产物
  • 配置 manualChunks 合理拆包
  • 图片资源压缩优化
  • 启用 Gzip/Brotli 压缩
  • 配置长期缓存策略
  • 设置性能预算
  • 在 CI/CD 中集成检查
  • 定期监控 Web Vitals

配置文件模板

// vite.config.ts - 生产环境优化完整配置
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import compression from 'vite-plugin-compression'

export default defineConfig(({ mode }) => ({
  plugins: [
    vue(),
    
    // 图片压缩
    ViteImageOptimizer({
      png: { quality: 75 },
      jpeg: { quality: 70 },
      webp: { quality: 70 },
      avif: { quality: 60 }
    }),
    
    // Gzip 压缩
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      threshold: 10240
    }),
    
    // Brotli 压缩
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 10240
    }),
    
    // 构建分析(只在需要时开启)
    process.env.ANALYZE && visualizer({
      open: true,
      filename: 'dist/stats.html',
      gzipSize: true,
      brotliSize: true
    })
  ].filter(Boolean),
  
  build: {
    target: 'es2015',
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: mode === 'production',
        drop_debugger: true
      }
    },
    
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/chunks/[name].[hash].js',
        assetFileNames: 'assets/[ext]/[name].[hash].[ext]',
        
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus') || id.includes('antd')) {
              return 'vendor-ui'
            }
            if (id.includes('echarts') || id.includes('d3')) {
              return 'vendor-charts'
            }
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'
            }
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'
            }
            return 'vendor-other'
          }
          
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) return `page-${match[1]}`
          }
        }
      }
    },
    
    chunkSizeWarningLimit: 500,
    sourcemap: mode !== 'production',
    manifest: true
  }
}))

性能目标参考

指标 优秀 一般
首屏 JS 体积 < 200KB 200-500KB > 500KB
总构建体积 < 2MB 2-5MB > 5MB
图片体积占比 < 30% 30-50% > 50%
压缩率 > 70% 50-70% < 50%
缓存命中率 > 80% 50-80% < 50%
FCP < 1.5s 1.5-2.5s > 2.5s
LCP < 2.5s 2.5-4s > 4s

三个核心原则

  1. 测量优先:没有数据的优化是盲目的
  2. 渐进改进:每次只优化一个指标
  3. 用户优先:始终以用户体验为导向

结语

优化的终极目标是让用户感受不到加载的存在。当用户打开我们的应用时,内容瞬间呈现,交互立即响应,这就说明我们的优化成功了!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

实战:基于 Vue3 与大模型的多模态“拍照记单词”应用构建与思考

作者 ETA8
2026年3月19日 22:31

随着大语言模型(LLM)能力的边界不断拓展,前端开发的范式正在发生微妙的变化。过去我们需要后端提供结构化的数据接口,现在前端可以直接与多模态模型对话,让应用具备“看”和“说”的能力。

今天我想分享一个小型的全栈实践案例:一个“拍照记单词”的应用。它的核心逻辑很简单:用户拍摄或上传一张生活照片,系统识别图片内容,提取一个适合初学者的英文单词,生成例句,并朗读出来。

虽然功能看似简单,但在实现过程中,涉及到了文件处理、多模态 API 调用、音频流处理以及 Prompt 工程等多个技术点。本文将剥离出核心代码逻辑,探讨其中的实现细节、设计考量以及潜在的优化空间。

一、核心交互与文件处理

在传统的文件上传场景中,我们通常将文件直接提交给后端。但在这个应用中,图片需要同时做两件事:

  1. 本地预览:让用户确认上传的内容。
  2. 发送给 LLM:作为多模态模型的输入。

1. 无障碍与样式控制的平衡

PictureCard 组件中,文件上传的实现采用了经典的 input + label 组合模式:

<input type="file" id="selecteImage" class="input" accept="image/*" @change="updateImageData">
<label for="selecteImage" class="upload">
    <img :src="imgPreview" alt="camera" class="img">
</label>

这里有两个细节值得注意:

首先是无障碍访问(Accessibility)。原生的 input[type="file"] 样式难以定制,且在不同浏览器上表现不一。通过 display: none 隐藏 input,并使用 label 关联 id,我们既获得了完全自由的样式控制权,又保留了语义化。当用户点击美观的相机图标时,实际上触发的是原生文件选择器。对于使用读屏器的视障用户,label 标签能准确传达“上传图片”的意图,这是开发中容易忽视但至关重要的细节。

其次是文件读取机制。为了将图片发送给 LLM,我们需要将其转换为 Base64 格式。这里使用了 HTML5 提供的 FileReader API:

const reader = new FileReader(); 
reader.readAsDataURL(file);
reader.onload = () => {
    const data = reader.result as string;
    imgPreview.value = data;
    emit('update-image', data);
}

readAsDataURL 会将文件内容读取为一个包含 MIME 类型的 Base64 字符串(例如 data:image/png;base64,...)。

  • 优点:格式统一,可以直接嵌入 JSON 发送给大多数多模态 API,同时也方便直接赋值给 img 标签的 src 进行预览。
  • 缺点:Base64 编码会使文件体积增加约 33%。如果图片过大,不仅影响传输速度,还可能超出 LLM 的 Token 限制。在实际生产中,通常需要在读取前对图片进行压缩或尺寸限制。

二、与大模型的对话:Prompt 工程与多模态

应用的核心智能来源于对 Kimi(Moonshot)多模态接口的调用。在 App.vue 中,我们构建了请求体。

1. 多模态输入的标准格式

目前主流的多模态模型(如 GPT-4V, Moonshot-v1-vision)在接收图片时,通常要求 messages 中的 content 字段是一个数组,分别包含文本和图片对象:

messages: [
  {
    role: 'user',
    content: [{
      type: 'image_url',
      image_url: { url: imageDate } // 这里是 Base64 或 HTTP URL
    }, {
      type: 'text',
      text: userPrompt
    }]
  }
]

这种设计允许模型同时“看”到图片并“读”到指令。需要注意的是,虽然代码中直接使用了 Base64,但如果图片较大,建议先上传至对象存储(OSS),将 HTTP URL 传给模型,以减少请求包体大小。

2. 结构化输出的重要性

userPrompt 的设计上,我们没有让模型自由发挥,而是严格限制了输出格式:

返回 JSON 数据:
{
  "representative_word": "图片代表的英文单词",
  "example_sentence": "结合英文单词和图片描述,给出一个简单的例句",
  "explaination": "...",
  ...
}

这是开发 AI 应用的一个关键原则:机器与人对话可以自然,但机器与代码对话必须严谨。

通过要求模型返回 JSON,我们可以直接 JSON.parse 结果,将单词、句子、解释分发到不同的 UI 区域。如果让模型自由返回文本,前端就需要编写复杂的正则去提取单词,这不仅脆弱,而且容易出错。此外,Prompt 中明确了词汇难度(A1~A2),这是产品价值的体现——我们不是在做一个翻译工具,而是在做一个适合初学者的教育工具。

三、音频生成与播放机制

当模型返回例句后,应用需要调用 TTS(Text-to-Speech)服务将文本转为音频。这里涉及到了二进制数据的处理。

1. Base64 到 Blob URL 的转换

TTS 接口返回的通常是音频文件的 Base64 数据。在 audio.ts 中,我们实现了一个 createBlobURL 函数:

const byteCharacters = atob(base64AudioData);
// ... 转换为 Uint8Array
const audioBlob = new Blob([new Uint8Array(byteArrays)], { type: 'audio/mp3' });
const blobURL = URL.createObjectURL(audioBlob);

这里有一个常见的疑问:为什么不直接使用 data:audio/mp3;base64,... 赋值给 audio 标签?

虽然 Data URI 可以直接播放,但在处理较长音频或高频调用时,Blob URL 方案更具优势:

  1. 性能:Blob URL 指向的是内存中的二进制对象,浏览器解码效率通常更高。
  2. 内存管理URL.createObjectURL 创建的引用是可以被显式释放的(通过 URL.revokeObjectURL)。虽然示例代码中为了简洁未展示释放逻辑,但在组件卸载时调用释放,可以有效防止内存泄漏。
  3. 类型安全:显式创建 Blob 可以确保 MIME 类型被浏览器正确识别,避免某些移动端浏览器对 Data URI 音频支持不佳的问题。

2. 音频格式的潜在风险

在代码审查中,我发现了一个值得注意的细节:

  • TTS 请求参数中设置的是 encoding: 'ogg_opus'
  • 但在创建 Blob 时,MIME 类型指定的是 audio/mp3

这可能会导致部分浏览器播放失败或无法识别时长。严谨的做法是根据 API 实际返回的音频流格式来设定 Blob 的 type,或者在 API 请求时直接要求返回 MP3 格式。这提醒我们在对接第三方服务时,必须严格核对输入输出的格式规范。

四、架构思考与安全隐患

在复盘整个项目时,除了功能实现,还有几个架构层面的问题需要深入探讨。

1. 前端密钥的安全风险

App.vue 中,我们看到了这样的代码:

'Authorization': `Bearer ${import.meta.env.VITE_KIMI_API_KEY}`

这是一个严重的安全隐患。 将 LLM 的 API Key 直接暴露在前端代码中,意味着任何查看网页源码的用户都可以获取你的密钥,从而盗用你的额度。

改进方案: README 中提到了技术栈包含 NestJS。正确的架构应该是:

  1. 前端发起请求到自有的 NestJS 后端。
  2. 后端在服务器端存储 API Key,并转发请求给 Kimi 和 TTS 服务。
  3. 后端可以做一层代理,同时实现限流、鉴权和日志记录。

目前的实现仅适合本地学习或内部演示,绝不可直接部署到公网。

2. 状态管理的解耦

当前逻辑集中在 App.vue 中,包括图片状态、单词状态、音频状态等。随着功能增加(例如历史记录、生词本),组件会变得臃肿。

建议引入状态管理库(如 Pinia),将“学习会话”作为一个 Store 管理。同时,将 generateAudiofetchLLM 封装为独立的 Service 层,与 UI 组件彻底解耦。这样不仅便于测试,也方便后续将 API 调用迁移到后端时,前端只需修改 Service 层的请求地址。

3. 用户体验的细腻处理

代码中实现了基础的加载状态(如“分析中..."),但在网络波动或 API 报错时,用户体验还可以更好:

  • 重试机制:LLM 接口偶尔会超时,提供“重试”按钮比直接报错更友好。
  • 音频预加载:在生成音频 URL 后,可以实例化 new Audio(url) 进行预加载,确保用户点击播放时无延迟。
  • 图片压缩:如前所述,在 FileReader 读取前,使用 Canvas 对图片进行压缩,能显著提升上传和解析速度。

五、总结

通过这个“拍照记单词”的小应用,我们实践了 Vue3 组合式 API 的组件通信,探索了 FileReader 与 Blob 的二进制处理,并深入体验了多模态大模型的接入流程。

技术本身并不是目的,解决用户痛点才是。在这个案例中,技术的价值在于将“生活中的任意场景”瞬间转化为“可学习的语言素材”,降低了语言学习的门槛。

对于前端开发者而言,拥抱 AI 不仅仅是学会调用 API,更在于理解如何设计 Prompt 以获得稳定的输出,如何处理多媒体数据流,以及如何在享受 AI 便利的同时,守住安全与性能的底线。希望这个案例能为你构建自己的 AI 应用提供一些实在的参考。

昨天以前首页

Vue-Vue2与Vue3核心差异与进化

2026年3月19日 21:05

前言

从 Vue 2 到 Vue 3,不仅仅是版本的跳跃,更是底层思想的革新。从 Object.definePropertyProxy,从 Options API 到 Composition API,Vue 3 在性能和开发体验上都实现了质的飞跃。本文将带你系统梳理两者的核心区别。

一、 响应式原理:从“属性拦截”到“对象代理”

响应式系统的升级是 Vue 3 性能提升的关键。

1. Vue 2:Object.defineProperty

  • 原理:初始化时通过递归遍历 data,为每个属性设置 gettersetter

  • 局限性

    • 无法检测到对象属性的新增删除
    • 无法直接监听数组索引的变化和 length 属性。
    • 必须使用 this.$set 等特有 API 来弥补。
    • 递归过程在处理大数据量时存在性能瓶颈。

2. Vue 3:ES6 Proxy

  • 原理:直接监听整个代理对象,拦截所有操作(如 get, set, deleteProperty, has 等)。

  • 优势

    • 原生支持:自动支持动态增删属性、数组下标修改。
    • 懒代理(Lazy Tracking) :只有当访问到深层属性时,才会动态将其转为响应式,大大提升了初始化速度。
    • 性能更好:省去了初始化时繁琐的递归遍历。

二、 编写模式:从“碎片化”到“模块化”

代码组织方式的改变直接影响了大型项目的维护成本。

1. Vue 2:选项式 API (Options API)

  • 痛点:逻辑被强行拆分在 datamethodscomputed 等固定选项中。当一个组件功能复杂时,同一个功能的代码会散落在各处,导致开发者反复上下滚动查找,难以维护。

2. Vue 3:组合式 API (Composition API)

  • 优势:通过 <script setup>,开发者可以按照功能逻辑将代码组织在一起。

  • 逻辑复用:可以轻松地将逻辑抽离成独立的 useHooks 函数,解决了 Vue 2 中 mixin 命名冲突和来源不明的问题。


三、 Vue 3 核心新特性与语法糖

1. 响应式新成员:ref vs reactive

  • ref:万能型。支持基本类型和引用类型,通过 .value 访问(模板中自动解包)。
  • reactive:对象型。仅支持引用类型,直接操作属性,无需 .value

2. defineModel:双向绑定的“减法”

在 Vue 3.4+ 中引入的 defineModel 极大地简化了父子组件通信:

  • Vue 2 做法:需要 props 接收值 + this.$emit('update:xxx') 触发更新。
  • Vue 3 新语法:子组件直接使用 const model = defineModel(),修改 model 的值会自动同步到父组件,代码量骤减。

3. 多根节点模板

  • Vue 2:模板内必须有一个唯一的根节点(通常是 <div>),否则报错。
  • Vue 3:原生支持多个根节点,减少了不必要的 DOM 层级,使 HTML 结构更简洁。

4. 异步处理神器:<Suspense>

  • 新增内置组件,专门用于处理异步组件的加载状态。它提供了 defaultfallback 两个插槽,可以优雅地展示“加载中”和“加载完成”的 UI 切换。

四、 总结:为什么要升 Vue 3?

类别 Vue2 Vue3
响应式原理 Object.defineProperty 逐个属性劫持 Proxy 代理整个对象,懒加载
编写模式 选项式API(Options API) 组合式API(Composition API +
模板规范 仅支持单个根节点 支持多个根节点
数据监听 无法监听对象增删、数组索引 原生支持对象增删、数组下标修改
组件双向绑定 props + emit 手动实现 defineModel 语法糖简化
异步加载 手动处理加载状态 内置 Suspense 组件

Vue2:数组/对象操作避坑大全

2026年3月19日 20:49

前言

在 Vue 2 开发中,你是否遇到过“明明数据变了,视图却没动”的诡异情况?这通常不是代码逻辑问题,而是由于 Vue 2 基于 Object.defineProperty 的响应式原理存在天然的局限性。本文将带你攻克这些响应式盲区。

一、 响应式的“硬伤”:为什么会失效?

Vue 2 在初始化阶段,会遍历 data 中的属性并使用 Object.defineProperty 将其转为 getter/setter

它的核心问题在于:

  1. 无法检测对象属性的添加或删除(因为它只在初始化时进行监听)。
  2. 无法检测数组索引的直接修改和长度变化

二、 对象操作:打破“属性新增”的僵局

1. 新增/删除属性

如果你直接通过 this.obj.newKey = value 赋值,Vue 是无法感知的。

  • 新增属性:使用 this.$set (或全局 Vue.set)。

    • 语法:this.$set(target, key, value)
    • 示例:this.$set(this.user, 'age', 18)
  • 删除属性:使用 this.$delete (或全局 Vue.delete)。

2. 批量修改属性

如果你需要一次性增加多个属性,不要写一堆 $setVue2 可以监听对象引用变化,最高效的方法是替换整个对象引用

// 这种方式 Vue 能够通过监听对象的引用变化来触发更新
this.user = Object.assign({}, this.user, {
  age: 18,
  gender: 'male'
});

// 批量更新user对象属性
this.user = {
  ...this.user,
  age: 20,
  gender: '男',
  address: '北京'
}

三、 数组操作:被“重写”的 7 个方法

在 Vue 2 中,直接执行 this.items[0] = 'new' 是不会触发更新的。解决方案同样是使用 this.$set,以及使用vue重写的相关数组方法。

1. 自动触发更新的方法

只要调用以下方法,Vue 就会自动检测到变化并更新视图:

  • push() / pop():队尾操作
  • unshift() / shift():队头操作
  • splice()最万能,可实现增、删、改。
  • sort():排序。
  • reverse():翻转。

2. 数组的特殊场景

  • 根据索引修改值

    • ❌ 错误:this.items[index] = newValue
    • ✅ 正确:this.$set(this.items, index, newValue)this.items.splice(index, 1, newValue)
  • 修改数组长度

    • ❌ 错误:this.items.length = 0 (清空数组失效)
    • ✅ 正确:this.items.splice(0)this.items = []

四、 进阶补充:Vue 3 是如何解决的?

  • Vue 3 使用了 ES6 Proxy:Proxy 代理的是整个对象而不是属性。

  • 优势:Proxy 可以原生监听到属性的动态添加、删除,以及数组索引的变化,因此在 Vue 3 中,你不再需要使用 $set 了!


五、 总结

  1. vue2对象新增属性:首选 this.$set,批量新增选 Object.assign

  2. vue2数组修改:养成使用 splicepush 等 7 个变异方法的习惯。

  3. 调试技巧:如果视图没更新,先用 console.log 确认数据是否变了,再检查是否触碰了上述响应式盲区。

Vue3:ref 与 reactive 超全对比

2026年3月19日 20:34

前言

在 Vue 3 的 Composition API 中,refreactive 是定义响应式数据的两大基石。很多初学者常纠结于“什么时候该用哪个”。本文将从底层原理到实战场景,带你彻底理清两者的区别。

一、 核心概念对比

1. ref:全能型选手

  • 定义:主要用于定义基本类型(String, Number, Boolean 等),也可以定义引用类型。

  • 本质:通过对原始值进行包装,生成一个具有 .value 属性的对象。对于引用类型,ref 内部会自动调用 reactive 来处理。

  • 访问控制

    • 在 JS 中必须通过 .value 访问;
    • <template> 模板中,Vue 会自动解包,直接写变量名即可,无需加 .value。

2. reactive:对象专家

  • 定义:专门用于定义引用类型(Object, Array, Map, Set)。

  • 本质:基于 ES6 Proxy 实现,直接代理整个对象。

  • 访问控制:像操作普通原生对象一样直接访问属性,无需 .value

    注意: 传入基本类型会触发 Vue 警告且丢失响应式。


二、 深度差异对比

特性 ref reactive
支持类型 基本类型 + 引用类型 仅限 引用类型
JS 访问方式 .value 直接访问属性
模板访问 自动解包,无需 .value 直接访问
底层实现 包装基本类型,内部调用 reactive 处理引用类型 基于 Proxy 深度代理整个对象
替换整个对象 支持 (ref.value = 新对象/新数组) 不支持(直接赋值会丢失代理,失去响应式)
解构支持 直接解构丢失响应式(需 toRefs 直接解构丢失响应式(需 toRefs

三、 使用场景:我该怎么选?

推荐使用 ref 的场景:

  1. 基本类型数据:计数器、开关状态、输入框的值。

  2. 需要重置的数据:例如从后端获取列表后,直接 list.value = res.data

  3. 简单组件逻辑:代码更清晰,.value 提醒这是一个响应式变量。

推荐使用 reactive 的场景:

  1. 复杂业务模型:包含多个相互关联属性的大对象(如用户信息、表单整组数据)。

  2. 追求原生感:不希望在逻辑代码中到处看到 .value

  3. 聚合数据:将一类变量聚合在一个对象中管理,减少变量声明。


四、 高频易错点

1. reactive 直接赋值整个对象会丢失响应式

let state = reactive({ count: 0 });
// ❌ 错误操作:这会导致 state 失去响应式,因为它变成了一个普通的普通对象
state = { count: 1 }; 

// ✅ 正确方案 A (ref):
const state = ref({ count: 0 });
state.value = { count: 1 };

// ✅ 正确方案 B (Object.assign):
Object.assign(state, { count: 1 });

2. 解构 reactive 数据丢失响应式

当你需要从一个响应式对象中提取属性并保持响应式时,必须使用 toRefs,否则会丢失响应式

const props = reactive({ title: 'Vue3', author: 'Gemini' });
// 直接解构:const { title } = props; -> title 只是一个普通的字符串
const { title } = toRefs(props); // -> title 变成了一个 ref,保持响应式

3. Watch 监听的差异

  • 监听 ref:默认只监听 .value 的变化,如果 ref 包裹的是对象,深度监听需要开启 { deep: true }

  • 监听 reactive:默认强制开启深度监听,且无法关闭。


📝 总结

  • ref 是万金油,虽然多了个 .value,但胜在灵活且不易出错。
  • reactive 适合组织复杂的对象数据,但要注意赋值和解构的陷阱。

借助AI,1周,0后端成本,我开源了一款Office预览SDK

作者 徐小夕
2026年3月19日 17:12

今天给大家分享一款我借助AI, 开发的一款开源 Office 文件预览SDK。

图片

我把它起名叫 jit-Viewer,刚好和我们做的 JitWord 协同AI文档相呼应。它主要的功能就是可以在浏览器中一键预览 Docx,PDF,Excel,PPT,Markdown,Txt等各种格式的文件。

图片

同时只需要3行代码,就可以轻松集成到Vue,React,Angular,Html项目中。话不多说,先上开源地址。

github:github.com/jitOffice/j…

国内镜像:gitee.com/lowcode-chi…

演示地址:jitword.com/jit-viewer.…

核心功能亮点

图片

jit-viewer 不依赖后端转换,而是直接在浏览器端解析 Office Open XML 格式(.docx/.xlsx/.pptx 的底层结构),最后通过 jit-viewer 封装的渲染器渲染成可视化组件。PDF 预览则是基于 PDF.js 做深度优化。

所以我们完全不依赖后端,同时目前支持的文件预览方式有:

  • 本地上传文件
  • 通过url地址直接预览文件

这2种方式基本上是用户需求最多的方式。

总结几个核心亮点,方便大家参考评估:

  1. 零后端依赖,纯前端渲染:无需配置任何后端服务,静态站点也能用
  2. 框架无绑定:一个SDK同时支持Vue/React/Angular,团队技术栈切换无成本
  3. 隐私安全:文件解析在浏览器本地完成,不上传服务器
  4. 性能优异:虚拟滚动 + Web Worker,大文件不卡主线程
  5. 扩展性强:插件化架构,可自定义渲染器、添加水印、集成审批流

这里我在补充一条,目前预览访问不仅能在PC端预览,还能直接在移动端预览:

图片

充分满足大家移动办公的需求。

同时为了提供开发人员对预览的样式控制,我还支持了预览控件:

  • 下载文件
  • 缩放重置
  • 旋转功能
  • 全屏
  • 主题切换
  • 获取文档信息

后续会持续优化一些更可控的功能供大家使用。

如何本地使用

我在 jit-viewer 文档中写了详细的本地使用教程,接下来给大家分享一下。

第一步,引入SDK:

<!-- 引入样式文件 -->
<link rel="stylesheet" href="jit-viewer.min.css">

<!-- 引入 JitViewer SDK -->
<script src="jit-viewer.min.js"></script>

第二步,创建预览容器:

<div id="viewer" style="width: 100%; height: 600px;"></div>

第三步,初始化实例:

// 创建预览器实例
const viewer = JitViewer.createViewer({
  file: 'document.pdf',      // 文件 URL
  filename: 'document.pdf',  // 文件名(可选)
  toolbar: true,             // 显示工具栏
  theme: 'light',            // 主题
  width: '100%',
  height: '600px',
  onReady: () => console.log('准备就绪'),
  onLoad: () => console.log('加载完成'),
  onError: (err) => console.error('错误:', err)
});

// 挂载到 DOM
viewer.mount('#viewer');

是不是非常简单?只需要3步,就能快速集成到你的系统中实现 Office 文件预览功能。

我在文档中也写了详细的API介绍,大家想定制SDK样式和交互,也可以参考文档:

图片

文档地址:jitword.com/jit-viewer.…

小小总结一下

图片

之所以要做这个项目,完全来自于之前的客户的一个需求,为了给我们的客户赋能,我们便做了这个开源SDK。

后续会继续迭代优化,实现更多文件类型的预览功能,大家有好的建议也欢迎留言区交流反馈~

对于AI,其实并不是全程参与开发(尤其是复杂的业务需求),大家短期内还是不用太神话AI的能力。

对于SDK的工程化方案(脚手架),我是完全交给AI来实现的,同时从文档的编写,网站demo的设计,也都是交给AI做的,到这里,AI基本能代替40%左右的工作了。

我提供的是SDK的产品需求,设计思路,技术选型,和设计风格,同时兼顾测试工程师的角色,这块其实是目前AI人机协作的常态。

所以技术能力还是需要,用好AI可能更好的为我们的工作和产品研发提效。

新的竞争力在于:架构设计的品味、安全风险的嗅觉、人机协作的智慧,以及对自己代码的深刻理解

github:github.com/jitOffice/j…

国内镜像:gitee.com/lowcode-chi…

前端空值处理规范:Vue 实战避坑,可选链、?? 兜底写法|项目规范篇

作者 SuperEugene
2026年3月19日 14:45

帮助同学们学会在前端真实业务项目里,到底该怎么写空值处理(?.、??、||、if判断、兜底逻辑),以及为什么这么选、会踩哪些高频坑,顺便帮你拉直JS/TS空值、真值假值的基础概念,助力写出规范可维护的团队级代码。

在这里插入图片描述

同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。

(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)

很多前端开发者都会遇到一个瓶颈:

代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。

想写出干净、优雅、可维护的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验

这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。

帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。

引子:为什么要专门聊“空值处理规范”?

一句话定位这篇文章:

教你在真实项目里,到底该怎么写空值处理( ?. ?? || if ** 判断、兜底逻辑),以及为什么这么选、会踩哪些坑**,顺便帮你把 JS/TS 的一些基础概念拉直。

适用人群:

  • 已经会写 JS / Vue,但概念有点混== null||?.?? 到底差在哪?

  • 刚入门前端的小伙伴:想从一开始就养成靠谱的代码习惯

  • 像我这样工作多年想回炉重造的工程师:系统校准一下“老习惯”是不是已经过时了 本文不会讲太多过度底层的规范条文,而是:

  • 围绕真实业务代码的写法

  • 配合 完整示例 + 场景解释

  • 重点放在:怎么选写法、为什么这么选、常见坑在哪里

一、先把“空值家族”讲清楚:null、undefined、空字符串、0、false…

日常开发中经常混在一起的几个值:


null           // 明确的“空值”,一般表示“这里有个位置,但现在没有值”
undefined      // 未定义,通常是“压根没传”、“没赋值”
''             // 空字符串
0              // 数字 0
false          // 布尔 false
NaN            // 不是一个合法数字

1.1 “真值/假值”概念(很关键)

在 JS 里,if (xxx) 判断的是“真值/假值(truthy / falsy)”,而不是严格意义上的 true/false。下面这些都是 falsy(假)

  • false
  • 0
  • -0
  • ''(空字符串)
  • null
  • undefined
  • NaN

其他的基本都被当成 truthy(真)

为什么要先讲这个?

因为 ||&& 这些逻辑运算符,走的就是“真值/假值”逻辑。

比如:


const value = 0;
const result = value || 100;
console.log(result); // 100,而不是 0

0 在 JS 里是假值,所以 value || 100 会拿到 100

这也是我们后面会反复提的一个大坑:“用 ** || ** 做默认值会把合法值 0/''/false 当成没传”

二、可选链 ?.:安全访问深层属性的标准写法

场景:从后端拿到一个复杂对象,但某一层可能是 null / undefined,直接访问就会炸:


// 假设 user 可能是 null
const city = user.profile.address.city; 
// TypeError: Cannot read properties of null (reading 'profile')

2.1 传统写法 VS 可选链

传统写法(防御式编程):


const city =
  user &&
  user.profile &&
  user.profile.address &&
  user.profile.address.city;

  • 可读性差
  • 很啰嗦
  • 稍微一改结构就容易漏一个判断

可选链写法:


const city = user?.profile?.address?.city;

  • 短很多
  • 语义清晰:如果中间任何一层是 null/undefined,就直接返回 undefined,而不是抛异常

2.2 在 Vue 模板里的使用

Vue 2 + Babel 环境Vue 3 默认 Vite 脚手架 一般都支持可选链。

在模板里:


<template>
  <div>
    <p>用户名:{{ user?.profile?.name || '未设置' }}</p>
    <p>城市:{{ user?.profile?.address?.city || '未知城市' }}</p>
  </div>
</template>

<script setup>
const user = ref(null);
// 后端请求完成后,再赋值
</script>

注意:模板表达式里也可以用 ?.||??,和 JS 里一样。

2.3 规范建议:何时必须用可选链?

我在项目里通常建议:

  • 从接口拿来的数据 + 多层嵌套对象默认用可选链
  • SDK / 第三方库返回的结构:尽量用可选链保护
  • 对于我们自己完全可控、结构固定的内部数据,可以不用(比如本地写死的配置)

统一规则示例:

  • 接口 Model 层(TypeScript 类型 + 接口封装):尽量把可选属性处理掉,往下传固定结构
  • 页面 / 组件层
    • 对于“接口原始数据”:用 ?. + 兜底字符串 / 兜底组件
    • 对于“内部状态”:减少可选,用默认值初始化

三、空值合并运算符 ??:给“真空”兜底,而不是给所有假值兜底

回顾刚才的例子:


const value = 0;
const result = value || 100;
console.log(result); // 100

如果 0 在业务里是合法值(比如“价格 0 元”、“数量 0 个”),那上面这行其实是错的。

我们想要的是:“只有在值为 null 或 undefined 的时候才给默认值”。

这就是 ?? 的作用。

3.1 || vs ?? 对比示例


console.log(0 || 100);       // 100
console.log(0 ?? 100);       // 0

console.log('' || '默认');   // '默认'
console.log('' ?? '默认');   // ''

console.log(null || '默认'); // '默认'
console.log(null ?? '默认'); // '默认'

console.log(undefined || '默认'); // '默认'
console.log(undefined ?? '默认'); // '默认'

总结一句话:

  • ||:只要左边是假值(包括 0 / '' / false / NaN / null / undefined),就用右边
  • ??:只有左边是 nullundefined 时,才用右边

3.2 在真实业务中的推荐用法

典型错误写法(很常见):


// 单价和数量来自接口
const price = item.price || 0;
const count = item.count || 1;
const total = price * count;

在这些场景会出错:

  • 价格为 0 元:price 会变成 0 || 0 → 0(这里还好)
  • 数量为 0:count 会变成 1(业务错了)
  • 用户输入了空字符串 '' 需要区分,但被直接当成没填

推荐写法:


const price = item.price ?? 0;  // 价格缺失才用 0
const count = item.count ?? 1;  // 只有未传 count 才默认 1

再比如配置项对象


function createDialog(options = {}) {
  const width = options.width ?? 400;         // 未传 width 才采用默认 400
  const closable = options.closable ?? true;  // 未传 closable 才用 true
}

3.3 在 Vue 模板中用 ??


<template>
  <div>
    <!-- 后端没给 nickName 时显示 '游客',但如果是空字符串就保持空 -->
    <p>昵称:{{ user.nickName ?? '游客' }}</p>
  </div>
</template>

规范建议:

  • 只要你的兜底逻辑只想针对 null/undefined,统一用 ??,不要用 ||
  • 保留 || 用于“逻辑或”场景,而不是“兜底默认值”。

四、兜底逻辑:不仅是运算符,还有“业务上的安全网”

可选链和空值合并属于“语法层面的防御”。

真实项目里,还需要“业务层面的兜底”,比如:

  • 数据为 null 时显示一个“空态组件”
  • 钱包余额为 null 时,不显示数字而是展示“--”
  • 列表为空时展示“暂无数据”

4.1 文本兜底:别让页面渲染出 undefined / null

错误示例:


<template>
  <div>
    <!-- 假设 user.name 可能 undefined -->
    <p>用户名:{{ user.name }}</p>
  </div>
</template>

页面可能出现:


<p>用户名:undefined</p>

推荐写法:


<template>
  <div>
    <p>用户名:{{ user?.name ?? '未设置' }}</p>
  </div>
</template>

如果你更谨慎一点,还可以抽成一个小工具函数或指令:


function displayText(value, fallback = '--') {
  if (value === null || value === undefined) return fallback;
  return String(value);
}

模板中:


<p>用户名:{{ displayText(user?.name, '未设置') }}</p>

4.2 数字兜底:0、null、undefined 要区分

常见场景:金额 / 数量 / 积分


<template>
  <div>
    <!-- 如果 amount 为 0,要显示 0 元,而不是 “--” -->
    <p>金额:{{ formatAmount(order?.amount) }}</p>
  </div>
</template>

<script setup>
function formatAmount(value) {
  if (value === null || value === undefined) return '--'; // 真空
  const num = Number(value);
  if (Number.isNaN(num)) return '--';                     // 非法数字
  return num.toFixed(2) + ' 元';
}
</script>

这里的思路是:

  • 对于“真空”(null/undefined)和“非法值”(NaN),直接兜底成 --
  • 对于合法的 0、10.5 等,按正常格式化逻辑展示

4.3 列表兜底:空数组 vs null/undefined

错误写法:


<template>
  <ul>
    <li v-for="item in list" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

<script setup>
const list = ref(null);
</script>

list 为 null 时,Vue 其实不会崩溃,但可读性很差,而且 TypeScript 下会疯狂报错。

推荐规范:

  • 列表类型的数据,初始化为 [],不要初始化为 null

  • 接口响应里如果是 null在数据层统一转成 [],不要把“既可以是数组又可以是 null”的结构传到视图层


// 假设后端可能返回 { list: null }
interface ApiResponse<T> {
  list: T[] | null;
}

async function fetchUsers(): Promise<User[]> {
  const res: ApiResponse<User> = await request('/api/users');
  return res.list ?? [];
}

Vue 组件里直接:


const users = ref<User[]>([]);

onMounted(async () => {
  users.value = await fetchUsers(); // 一定是数组
});

好处:

  • 模板里 v-for="user in users" 不用可选判断
  • 业务逻辑中也不用 if (!users) 乱判
  • 类型更干净,TS 也容易推断

五、可读性 vs 防御性:别让“防空代码”毁了代码结构

经常看到这样的代码:


if (user && user.profile && user.profile.address && user.profile.address.city) {
  showCity(user.profile.address.city);
} else {
  showDefaultCity();
}

可读性非常差。我们可以结合 ?. 和业务逻辑重写:

5.1 利用中间变量提高可读性


const city = user?.profile?.address?.city;

if (city) {
  showCity(city);
} else {
  showDefaultCity();
}

如果业务含义更复杂,比如:

  • city 为空字符串也视为没填

可以:


const rawCity = user?.profile?.address?.city;
const city = rawCity?.trim(); // string 或 undefined

if (!city) {
  showDefaultCity();
} else {
  showCity(city);
}

规范建议:

  • 不要在 if (...) 里面写一大串可选链,可以先提取出来
  • 对于复杂逻辑(例如 if (a && b && c && d)),考虑拆成几个语义明确的变量

六、项目中推荐的“空值处理规范(示例版)”

以下是一份可直接落地到团队规范里的示例,你可以根据团队实际情况调整。

6.1 基础规则

  • 规则 1:接口层统一做“空值归一化”
    • 列表字段:null / undefined 统一转成 []
    • 数字字段:null / undefined 转成约定好的业务默认(如 0),或者保持 null,但要有清晰设计文档
    • 字符串字段:如果是必展示项,可以转 '',或保留 null,但组件层要有兜底文案
  • 规则 2:组件 / 页面层永远不要直接信任后端
    • 访问深层属性一律用 ?.
    • 模板输出中不要让 null / undefined 直接裸露
  • 规则 3:兜底默认值尽量用 ??,而不是 ||
    • 只有当你有意要把 0 / '' / false 也视为“空”时,才可以用 ||

6.2 风格对比示例(推荐 vs 不推荐)

不推荐:


// 1. 访问深层属性不做保护
const city = user.profile.address.city;

// 2. 用 || 做默认值
const price = item.price || 0;
const count = item.count || 1;

// 3. 列表用 null 表示“还没加载”
const list = ref(null);

推荐:


// 1. 使用可选链保护
const city = user?.profile?.address?.city;

// 2. 用 ?? 严格处理 null/undefined
const price = item.price ?? 0;
const count = item.count ?? 1;

// 3. 列表统一用 [] 作为初始值
const list = ref([]);

在 Vue 模板中的统一写法示例:


<template>
  <div>
    <p>用户名:{{ user?.name ?? '未设置' }}</p>
    <p>年龄:{{ user?.age ?? '--' }}</p>

    <p>余额:{{ formatAmount(account?.balance) }}</p>

    <ul v-if="orders.length">
      <li v-for="order in orders" :key="order.id">
        订单号:{{ order.id }},金额:{{ formatAmount(order.amount) }}
      </li>
    </ul>
    <p v-else>暂无订单</p>
  </div>
</template>

<script setup>
const user = ref(null);
const account = ref(null);
const orders = ref([]); // 一定是数组

function formatAmount(value) {
  if (value === null || value === undefined) return '--';
  const num = Number(value);
  if (Number.isNaN(num)) return '--';
  return num.toFixed(2) + ' 元';
}
</script>

七、常见踩坑案例拆解

7.1 “把 0 当成没填”——报表类页面的大坑

需求:展示一个指标的环比增长率,后端字段 growthRate,可能是:

  • 0:说明没涨没跌
  • 正数:增长
  • 负数:下降
  • null:没有数据

错误写法:


<p>环比:{{ growthRate || '--' }}%</p>

growthRate = 0 时,会显示 --%,业务含义严重错误。

正确写法:


<p>环比:{{ growthRate ?? '--' }}{{ growthRate === null || growthRate === undefined ? '' : '%' }}</p>

或者包装一下:


function displayPercent(value) {
  if (value === null || value === undefined) return '--';
  return `${value}%`;
}

模板:


<p>环比:{{ displayPercent(growthRate) }}</p>

7.2 “深层属性访问炸页面”——常见于接口变更

场景:后端有一天把 user.profile 改成 user.info,但你代码里到处是:


user.profile.address.city

迁移时推荐策略:

  1. 先统一加可选链防御(短期止血):

const city = user?.profile?.address?.city;

  1. 在“数据适配层”做映射,避免在视图层直接跟后端结构硬绑定:

interface UserViewModel {
  city?: string;
  // ...
}

function mapUserDtoToViewModel(dto: any): UserViewModel {
  const profile = dto.profile || dto.info || {};
  return {
    city: profile.address?.city,
    // ...
  };
}
  1. 视图层只用 viewModel.city,再配合兜底:

<p>城市:{{ user.city ?? '未知城市' }}</p>

这样即使后端再改结构,你只需要改映射函数,不会到处是 ?. 打补丁。

八、结合 TypeScript:从“到处防空”升级为“类型上减少空值”

如果你的项目已经用 TypeScript,可以进一步 把“空值问题”提前到类型设计阶段解决

8.1 接口类型:把“可选”缩到最小

错误示例(很多后端生成工具会这样):


interface UserDto {
  id?: number;
  name?: string;
  age?: number | null;
  address?: {
    city?: string;
  } | null;
}

视图层到处是:


user?.address?.city ?? '未知城市'

更好的做法是:

  • 在“接口模型”层承认这些都是可选

  • 但在往页面传的时候,通过构造 ViewModel 把这些变成“非可选 + 有默认值”


interface UserViewModel {
  id: number;
  name: string;
  age: number | null;   // 业务上允许为 null
  city: string;         // 至少有兜底
}

function toUserViewModel(dto: UserDto): UserViewModel {
  return {
    id: dto.id ?? 0,                         // 或抛错,看业务
    name: dto.name ?? '未命名用户',
    age: dto.age ?? null,
    city: dto.address?.city ?? '未知城市',
  };
}

组件里就可以大胆用:


<p>用户名:{{ user.name }}</p>
<p>城市:{{ user.city }}</p>

而不是到处防空。

九、落地建议:如何在现有项目里逐步推行这套规范?

9.1 从“新代码”开始做对

  • 自己写的新组件、新方法,从一开始就用 ?.??
  • 审 PR 的时候,对用 || 做默认值的地方特别敏感,看清楚是否需要保留 0/''/false

9.2 为高风险页面补一层“空值巡检”

优先排查:

  • 面向 C 端用户的关键页面(订单、支付、结算)
  • 报表、数据面板类页面(数字特别多)

从这些点切入:

  • 所有深层属性访问,加上可选链或前置的空值判断
  • 所有数值展示,考虑是否需要 formatXXX 方法来统一兜底逻辑
  • 所有默认值逻辑,检查 || 能否替换为 ??

9.3 写到团队规范 / README / Contributing 里

可以直接摘抄下面一段到你们项目的规范文档里:

空值处理规范(摘要)

  1. 从接口拿到的原始数据,访问深层属性一律使用可选链 ?.
  2. 兜底默认值优先使用空值合并运算符 ??,只有在需要把 0 / '' / false 也当成“空”的场景才使用 ||
  3. 列表数据初始化为 [],不要用 null 表示“尚未加载”。接口返回 null 时在数据适配层统一转为 []
  4. 数字和金额展示需通过统一的格式化方法处理,避免页面出现 NaNundefined
  5. 模板中禁止直接输出可能为 null / undefined 的字段,必须有兜底显示(如 '--''未设置' 等)。

十、总结:把“空值处理”当成一个硬规范,而不是临时脑补

  • 可选链 ?.:用来安全访问深层属性,防止“Cannot read properties of undefined” 直接把页面干崩。
  • 空值合并 ??:只在 null / undefined 时兜底,避免误伤合法的 0 / '' / false
  • 兜底逻辑:不仅是语法问题,更是业务体验和数据安全网的问题,最好沉淀为项目级规范,而不是随手一写。

技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护

哪怕每次只吃透一条规范,长期下来,差距会非常明显。

后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。

我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~

前端代码注释规范:Vue 实战避坑,让 3 年后的自己还能看懂代码|项目规范篇

作者 SuperEugene
2026年3月19日 14:41

一套真正能落地的前端代码注释规范,从 Vue 项目实战出发,告诉你注释该写什么、不该写什么,避开常见坑点,写出让 3 年后的自己还能看懂的可维护代码。

在这里插入图片描述

同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。

(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)

很多前端开发者都会遇到一个瓶颈:

代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。

想写出干净、优雅、可维护的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验

这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。

帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。

前言:为什么要认真对待“写注释”这件小事?

你有没有遇到过这些场景:

  • 半年前自己写的业务,今天改个小需求,打开文件之后第一反应:“这谁写的垃圾代码?”,再一看作者:是自己。

  • 接手别人老项目,逻辑绕来绕去,偶尔看到一行注释:// TODO// 这里有点问题,先这么写……然后就没有然后了。

  • 为了“规范”,团队强行要求每个函数、每个变量都加注释,结果:注释和代码一起过期,甚至误导后来的人。

这篇文章就想解决一个现实问题:

日常写代码时,注释到底该怎么写?为什么这么写?坑会踩在哪?

目标是:让 3 年后的自己和队友,打开代码就能快速搞懂上下文,而不是骂人。

本文不是讲晦涩的底层原理,而是站在一线开发、项目规范的视角,用 Vue / 前端开发场景来聊聊“代码注释规范”。

一、第一原则:好代码胜过好注释,但没有注释也不一定是好代码

1.1 一句话核心原则

能用清晰的命名和结构表达含义,就不要用注释补课。注释只做代码无法表达的“额外信息”。

很多团队会陷入两个极端:

  • 极端 1:注释洁癖“好的代码不需要注释”,结果写一堆晦涩难懂的缩写变量,没人看得懂。

  • 极端 2:注释狂魔几乎每一行都要注释:

    
    // 声明一个变量 a
    let a = 1;
    // a 加 1
    a++;
    
    

    这种注释只会浪费时间、增加维护成本。

正确姿势:

  • 优先改代码,让代码本身更清晰(变量名、函数名、拆分方法、抽象组件……)

  • 其次用注释补充“代码表达不到的信息”,例如:

    • 为什么要这么写(业务背景 / 历史原因 / 兼容性)
    • 注意事项(性能、边界条件、已知坑)
    • 和其他模块的约定(接口协议、调用顺序)

二、注释的四大黄金场景:该写什么?

下面是我在项目里常用、非常推荐的四类注释场景。

2.1 解释“为什么这么写”(Why),而不是“代码在干嘛”(What)

What 代码自己能看出来,Why 只能靠你写出来。

❌ 错误示例:只是重复代码


// 获取用户列表
const users = await fetchUsers();

  • 这行注释几乎就是在重复变量名,没有信息增量

✅ 推荐示例:解释设计/业务原因


// 这里不能直接用缓存的用户列表:
// 1. 用户状态(在线/离线)是实时的
// 2. 后端会根据当前登录态过滤可见用户
// 所以每次都强制请求最新数据
const users = await fetchUsers({ forceRefresh: true });

这里的注释说明了为什么不能优化成缓存,以后有人想“优化性能”时,看到注释就会收手,避免踩坑。

2.2 标记“约定”和“前置条件”:别人需要遵守什么?

在 Vue 组件、工具函数、API 调用中,最容易出问题的往往不是“实现细节”,而是使用前提

  • 参数有没有默认值?
  • 有哪些边界情况?
  • 调用顺序有没有依赖?

✅ Vue 组件示例:在 props / emits 上写注释


// UserForm.vue <script setup lang="ts">
interface Props {
  /**
   * 表单模式:
   * - 'create':新建用户,所有字段可编辑
   * - 'edit':编辑用户,用户名不可修改
   * - 'readonly':只读模式,所有字段禁用
   */
  mode: 'create' | 'edit' | 'readonly';

  /**
   * 编辑/只读模式下必传:
   * 后端返回的完整用户信息。
   * create 模式下可以不传(内部会使用默认值)
   */
  user?: User;
}

const props = defineProps<Props>();

/**
 * 表单提交事件:
 * - create: 提交的 user.id 由后端生成
 * - edit: 必须包含原有的 user.id
 */
const emit = defineEmits<{
  (e: 'submit', payload: User): void;
}>();

这里注释的作用非常明确:

  • 告诉你 mode 不同模式的差别
  • 告诉你 user 在什么模式下是必传的
  • 告诉你 submit 的 payload 长什么样

重点:这类注释是“契约”的一部分,写在类型(interface / props / emits)附近最合适。

2.3 记录“历史遗留”和“坑点说明”:这块代码为什么这么丑?

有些代码你也知道写得不优雅,但短期内又不能重构,比如:

  • 老接口的奇怪字段命名
  • 历史版本遗留的时间格式
  • 奇怪的兼容写法(低版本浏览器 / 特定设备)

与其未来被队友(或自己)怒喷:

“这谁写的?怎么这么鬼畜?”

不如提前写清楚原因。

✅ 示例:兼容老接口


/**
 * 注意:后端这个接口是老系统保留的,字段命名非常诡异。
 * - 'usr_nm' 对应用户姓名
 * - 'crt_tm' 是创建时间字符串,格式为 'YYYY/MM/DD HH:mm:ss'
 * 暂时不能动这个接口,只在这里统一做一次映射。
 */
function normalizeLegacyUser(raw: any): User {
  return {
    id: raw.id,
    name: raw.usr_nm,
    createdAt: dayjs(raw.crt_tm, 'YYYY/MM/DD HH:mm:ss').toDate(),
  };
}

以后谁要改这个接口时,看到注释就会明白:

  • 这是历史债务,不是你写代码水。
  • 如果要改,要 连后端 / 老系统一并考虑

2.4 对复杂算法 / 业务流程做“概览说明”:给后人一张思维导图

有些模块就算代码写得再优雅,逻辑本身就是复杂的

  • 多步骤审批流
  • 复杂的优惠券 / 价格计算规则
  • 权限控制(菜单 + 按钮 + 数据权限)

这种时候,不要指望“代码自解释”,加一段流程性注释是对所有人的救赎。

✅ 示例:订单价格计算(假设你在 calculateOrderPrice.ts 里)


/**
 * 订单价格计算规则(简化版):
 *
 * 1. 基础金额 = 所有商品单价 * 数量 之和
 * 2. 商品级优惠:
 *    - 满减券:优先按商品分类应用,不能跨分类凑单
 *    - 折扣券:在满减之后应用,最多 2 张
 * 3. 订单级优惠:
 *    - 平台券:在所有商品级优惠之后应用
 *    - 封顶逻辑:总优惠金额不能超过基础金额的 30%
 * 4. 运费:
 *    - 满 99 元包邮
 *    - 其他情况按地区和重量计算
 *
 * 注意:
 * - 所有金额都用「分」为单位在内部计算,避免浮点误差
 * - 对外展示时再转换为「元」
 */
export function calculateOrderPrice(order: Order): OrderPriceDetail {
  // 具体实现略
}

这里注释的价值在于:

  • 给出了整体流程(按步骤)
  • 标明了关键约束(封顶 30%、单位是“分”)
  • 以后别人改逻辑时,有一个可以“对齐口径”的地方

三、哪些注释是坚决不要写的?

知道“该写什么”之后,更重要的是:哪些注释写了只会拖团队后腿?

3.1 重复代码的注释:浪费时间 + 增加维护成本

❌ 示例 1:重复变量名


// 用户名称
const userName = getUserName();

❌ 示例 2:重复函数名 / 类型名


/**
 * 获取用户列表
 */
function getUserList() { ... }

这些注释的问题:

  • 没有额外信息
  • 只要一改函数名/变量名,注释就有可能不一致
  • 时间久了变成“看着像对的,其实是错的”

解决办法:

  • 优先把命名改清晰:getListgetUserListdatauserList / formState
  • 确实没啥要补充的,就不要写注释,空着反而更安全。

3.2 “心情日志”注释:TODO / FIXME 不写清楚内容

❌ 典型反面教材:


// TODO: 后续优化
// FIXME: 有 bug

半年后你自己也不知道:

  • 要优化什么?
  • 有什么 bug?复现步骤是什么?
  • 是否已经修了?是否还有影响?

✅ 推荐写法:


// TODO(v2.1): 表格数据量>1w时,滚动卡顿,需要引入虚拟列表
// 影响范围:订单列表、用户列表


// FIXME(2025-03-18 by 张三):
// 后端偶发返回重复的 orderId,导致 set 里丢数据
// 临时方案:前端用 (orderId + createdAt) 拼接作为 key,等后端修复后移除

规范建议:

  • TODO / FIXME 注释建议包含:

    • 触发条件 / 复现方式
    • 影响范围
    • (可选)目标版本/时间 & 责任人缩写
  • 团队可以规定:重要 TODO / FIXME 必须对应 Jira/禅道/飞书任务号,比如:


// TODO(JIRA-1234 v2.2): 支持多语言,先写死为中文

3.3 和真实逻辑不一致的注释:比没有注释更可怕

注释一旦和代码不一致,就会变成误导信息

❌ 示例:注释没更新


/**
 * 返回 true 表示用户未登录
 */
function isLoggedIn() {
  return !!localStorage.getItem('token');
}

显然逻辑是“有 token 才是登录”,但注释写反了。

如果后来别人只看注释不看实现,很容易写出一堆反逻辑的代码。

经验结论:

写过时注释 = 欺骗未来的同事。

写了就要维护,维护不了就少写。

所以在团队规范里可以明确:

  • 改动逻辑时,必须同步检查相关注释是否仍然正确
  • Code Review 时,把**“注释是否仍然成立”**当成一个检查点

3.4 写在实现细节里的“小说故事”:越写越乱

有同学特别喜欢在函数内部“边写边感想”,比如:


function fetchData() {
  // 这里先判断一下是不是有缓存
  // 如果有缓存的话就不用请求接口了
  // 但是这里我们又觉得可能缓存会不准
  // 所以又加了一个时间戳的判断
  // 总之就是很复杂,先这么写吧……
}

这种注释的问题:

  • 没有结构,像碎碎念日记
  • 讲了一堆感受,没有讲清楚最终规则
  • 以后别人看的时候,只会更迷惑

更好的做法:

  • 把真正关键的规则整理成条目
  • 其他的犹豫、不确定、吐槽,写到需求文档 / 评审记录里,而不是代码里

✅ 重写示例:


/**
 * 缓存策略说明:
 * 1. 默认命中缓存,避免重复请求
 * 2. 如果缓存时间超过 5 分钟,则强制请求最新数据
 * 3. 切换用户时,必须清空缓存(用户隔离)
 */
function fetchData() {
  // 实现略
}

四、不同层级怎么写?以 Vue 项目为例的一套落地规范

下面从 Vue 项目常见几层结构出发,给一套可直接落地到项目里的注释建议

4.1 组件层(Vue SFC):注释重点放在哪里?

4.1.1 props / emits / expose 是最值得写注释的地方

因为它们构成了组件的“对外接口”。

✅ 示例:表单组件


<script setup lang="ts">
interface Props {
  /**
   * 表单初始值:
   * - 不传则使用内部默认值
   * - 传入时会完全覆盖默认值(不要只传部分字段)
   */
  modelValue?: UserFormModel;

  /**
   * 是否立即在 mounted 后拉取远程选项数据
   * 默认 true;如果父组件要控制时机,可以传 false 后手动调用 `reloadOptions`
   */
  autoLoadOptions?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  autoLoadOptions: true,
});

const emit = defineEmits<{
  /**
   * 表单提交成功时触发
   * payload 包含表单内的所有字段
   */
  (e: 'submit', payload: UserFormModel): void;

  /**
   * 任意字段变化时触发(用于实时保存草稿)
   */
  (e: 'update:modelValue', value: UserFormModel): void;
}>();

defineExpose({
  /**
   * 重新拉取远程下拉选项
   */
  reloadOptions,
});
</script>

这里的注释能让你在不看实现的情况下,就知道怎么用这个组件,这就是高价值注释。

4.1.2 复杂模板逻辑,优先拆组件,其次写块级注释

当模板里出现大量条件判断 / 嵌套 v-if / v-for 时:

  1. 优先选择“拆小组件 / 抽函数”
  2. 仍然复杂时,可以在逻辑块上方加一段块级注释,说明大体意图

✅ 示例:


<template>
  <!-- 展示可见的菜单项:
       1. 已被后端标记为启用
       2. 当前用户有权限
       3. 如果是移动端,只显示前 5 个
  -->
  <MenuItem
    v-for="item in visibleMenuItems"
    :key="item.id"
    :item="item"
  />
</template>

这里注释的作用:

  • 总结了 visibleMenuItems过滤规则
  • 方便别人查找时快速定位逻辑(比如“为什么这个菜单在移动端消失了?”)

4.2 业务逻辑层(hooks / composables / services)

很多 Vue 3 项目会把复杂逻辑拆到:

  • useXXX.ts(逻辑复用)
  • xxxService.ts(调用后端接口 + 业务规则)

这部分逻辑往往最需要注释,但注释也最容易乱写。

4.2.1 统一写在函数/方法签名上方,说明职责和返回值

✅ 示例:组合式函数


/**
 * 订单列表的分页 + 筛选逻辑:
 * - 对外暴露响应式数据:list、loading、pagination
 * - 支持关键字搜索、状态筛选
 * - 初始化时自动加载一次数据
 */
export function useOrderList() {
  const list = ref<Order[]>([]);
  const loading = ref(false);
  const pagination = reactive({
    page: 1,
    pageSize: 20,
    total: 0,
  });

  // ...

  return {
    list,
    loading,
    pagination,
    reload,
    resetFilters,
  };
}

4.2.2 和后端接口交互的地方,注释协议差异/约束

✅ 示例:Service 层


/**
 * 获取订单详情:
 * - 后端只在 status='PAID' 时返回 payInfo 字段
 * - 如果订单已退款,refoundInfo 字段存在但可能为空对象
 * - 接口有 500ms 左右的延迟,注意不要在输入框输入时频繁调用
 */
export async function fetchOrderDetail(orderId: string): Promise<OrderDetail> {
  const { data } = await request.get(`/api/orders/${orderId}`);
  return normalizeOrderDetail(data);
}

这些信息如果不写在这里,很难在代码中第一时间发现,却又对上层调用逻辑影响极大。

4.3 工具层(utils / helpers):何时需要注释?

  • 通用的小工具函数,命名清晰时可以不用注释:

    
    export function formatPrice(amountInCent: number): string { ... }
    
    
  • 如果函数有一些隐含约束或性能特征,就应该注释说明:

✅ 示例:


/**
 * 深拷贝对象(仅用于小对象):
 * - 基于 JSON 序列化,不支持函数 / Date / Map / Set
 * - 遇到循环引用会抛错
 * 适合用于「接口 mock 数据」等简单场景,不要在核心路径频繁使用。
 */
export function simpleClone<T>(obj: T): T {
  return JSON.parse(JSON.stringify(obj));
}

五、团队层面的“注释规范建议”:可以直接抄到你们 RULE.md 里

下面给一份可以直接落地的团队规范草稿,你可以根据实际情况微调。

5.1 总体原则

  • P1:注释是代码的一部分,写了就要维护。
  • P2:注释说明“为什么 / 有什么坑 / 有什么约定”,不要“翻译代码”。
  • P3:宁可少写,也不要写错;宁可写在“合适位置”,也不要乱丢。

5.2 “必须注释”的场景

  • 对外接口:
    • 组件的 props / emits / expose
    • 公共工具函数 / Service 层函数的入参、返回值说明(特别是有约束时)
  • 复杂业务逻辑 / 算法:
    • 在函数 / 模块顶部写整体流程说明或规则列表
  • 历史遗留 / 兼容代码:
    • 必须说明历史背景 / 兼容对象 / 计划替换方案
  • TODO / FIXME:
    • 必须写明触发条件 / 影响范围 / 预期目标
    • 建议关联任务号(如:TODO(JIRA-1234)

5.3 “禁止/不鼓励”的注释

  • 重复代码内容的注释(变量名 / 函数名已经表达清楚)
  • 空泛的 TODO / FIXME(未说明问题和上下文)
  • 纯吐槽 / 情绪化注释
  • 长篇大论但没有结构的“感想式注释”

六、一个完整的小案例:从“糟糕注释”到“可维护代码”

下面用一个实际例子,演示如何从“混乱风格”改到“规范易读”。

6.1 初版(很多人项目里真实存在的写法)


<!-- OrderList.vue -->
<script setup lang="ts">
// 订单列表组件

const data = ref([]);
const loading = ref(false);
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);

// 获取列表
async function getList() {
  loading.value = true;
  // 调接口
  const res = await request.get('/api/list', {
    params: {
      p: page.value,
      ps: pageSize.value,
    },
  });
  // 处理数据
  data.value = res.data.list;
  total.value = res.data.total;
  loading.value = false;
}

// TODO: 后面要加筛选
</script>

<template>
  <!-- 列表 -->
  <Table :data="data" />
</template>

问题:

  • 命名不清晰(data / getList / /api/list
  • 注释几乎都是废话,没有说明任何约束
  • TODO 没有说明到底怎么“要加筛选”

6.2 改进版:结合命名 + 注释一起升级


<!-- OrderList.vue -->
<script setup lang="ts">
/**
 * 订单列表页:
 * - 支持分页
 * - 计划后续增加:状态筛选、关键字搜索(见 TODO)
 */
import { fetchOrderList } from '@/services/order';

const orders = ref<Order[]>([]);
const loading = ref(false);
const pagination = reactive({
  page: 1,
  pageSize: 10,
  total: 0,
});

/**
 * 拉取订单列表:
 * - 后端的页码从 1 开始(不要传 0)
 * - pageSize 最大不超过 100,否则后端会报错
 */
async function loadOrders() {
  loading.value = true;
  const res = await fetchOrderList({
    page: pagination.page,
    pageSize: pagination.pageSize,
  });
  orders.value = res.list;
  pagination.total = res.total;
  loading.value = false;
}

// TODO(v2.1): 增加筛选条件(状态 / 下单时间区间)
// - 与后端对齐接口 GET /api/orders:新增 status / startAt / endAt 参数
// - UI 上用折叠面板隐藏高级筛选
</script>

<template>
  <OrderTable
    :data="orders"
    :loading="loading"
    :pagination="pagination"
    @change="loadOrders"
  />
</template>

这里我们做了几件事:

  • 改变量名:dataordersgetListloadOrders
  • 提取 Service 层:fetchOrderList(便于复用与测试)
  • 用注释补充约束和未来计划,而不是重复代码

这就是一个**“代码 + 注释配合良好”的例子**。

七、如何把“注释规范”写成一篇能发 CSDN 的文章?

你可以按本文结构,稍作润色,就能产出一篇完整的博客。建议大致结构如下:

  1. 引子(痛点故事)
    • 自嘲+团队真实场景,引出“注释到底该不该写”的问题
  2. 第一原则:好代码优先,注释补充 Why & 限制
  3. 四大高价值注释场景
    • Why / 约定 / 历史坑点 / 复杂流程概览
  4. 四类反面注释示例
    • 重复代码、空 TODO/FIXME、过期注释、碎碎念
  5. 结合 Vue 项目结构的一套实践
    • 组件层、业务层、工具层分别给建议和示例
  6. 前后对比小案例
    • “糟糕版” vs “改进版”
  7. 总结 + 个人习惯分享
    • 比如:写完函数先写注释再实现、Review 时检查注释等

你可以直接把上文复制到 CSDN,稍微调整标题 / 小节顺序,并补充你自己项目中的真实故事和代码片段,会更有代入感和说服力。

八、结语:写给 3 年后的自己

注释不是给现在的你看的,是给“未来的你”和“曾经不认识你的同事”看的。

  • 多写一点“为什么这么写”,少写一点“这行在干嘛”
  • 多写一点“有什么坑 / 有什么约束”,少写一点“将来再说”
  • 写得少,但每一行都值钱,比写一堆废话强太多

技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护

哪怕每次只吃透一条规范,长期下来,差距会非常明显。

后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。

我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~

Vue3 JSX 语法速查:v-model、事件、插槽一网打尽

作者 cmd
2026年3月19日 11:07

Vue3 JSX 以 JS 原生逻辑替代模板语法,核心转换规则如下:

  • 条件渲染v-if&&/ 三元表达式;v-show 直接保留
  • 循环渲染v-for → 数组 map 遍历
  • 事件处理:事件名驼峰化,修饰符用 withModifiers 包裹
  • 双向绑定v-model 支持基础、自定义名、修饰符及多 model 场景
  • 插槽:支持默认 / 具名 / 作用域插槽,渲染与传递写法清晰
import { ref } from 'vue'
export default function add(props, ctx) {
    let visible = props.visible
    let form = props.form
    let plus = props.isPlus
    let addFormRef = ref()
    async function handleOk() {
        const error = await addFormRef.value.validate()
        if (error) return
        let url = plus ? 'insert' : 'update'
        let msg = plus ? '新增成功' : '更新成功'
        proxy.post(`/api/mapbus/pm/project/${url}`, form).then((res) => {
            proxy.$message.success(msg)
            ctx.emit('success')
        })
    }
    return (
        <a-drawer visible={visible} title={plus ? '新建' : '编辑'} width="30%" onOk={handleOk}>
            <a-form ref={addFormRef} model={props.form} auto-label-width>
                <a-form-item field="projectName" label="项目名称" validate-trigger="blur" rules={{required: true, message: '项目名称必填'}}>
                    <a-input v-model={form.projectName}></a-input>
                </a-form-item>
                <a-form-item field="remark" label="说明">
                    <a-input v-model={form.remark}></a-input>
                </a-form-item>
            </a-form>
        </a-drawer>
    )
}

一、常规逻辑

  • v-if: 转换成 js逻辑,三元表达式也可;
  • v-show: 支持;可直接写成v-show;
  • v-for:转换成js逻辑,forEach,map...等数组循环方式;
  • 事件:依驼峰命名方式写,onClickonMouseOver...等等
  • js: 用花括号包起来;
  • 对象:用两个花括号,外围的括号是js的括号,里面括号才是对象的括号;

二、事件

  1. 事件以驼峰命名方式定义;

  2. 事件要是有修饰符的话:

  • 以常规驼峰命名写;
<input
  onClickCapture={() => {}}
  onKeyupOnce={() => {}}
  onMouseoverOnceCapture={() => {}}
  />
  • 可以使用 withModifiers 函数
<div onClick={withModifiers(() => {}, ['self'])} />

三、v-model

Vue3 jsx新特性,支持v-model使用

(一)、modelValue

如果组件的v-modelmodelValue的话,那使用很简单;

renderDropdown(h){
const value = "value"
return <custom-component v-mode={value}>
code...
</custom-component>
}

自定义value

比如v-model:visible=show写法如下:

renderDropdown(h){
  const show = "true"
  return <el-popover v-model={[show, 'visible']}>
    code...
  </el-popover>
}

修饰符

  1. v-model后面跟着,使用(_)代替(.);vModel_trim = {value}
  2. withModifiers
// template<input v-model="val" />
<input v-model:name="val">
<input v-model.trim="val">
<input v-model:name.trim="val">

// tsx
<input v-model={val} />
<input v-model={[val, 'name']} />
<input v-model={[val, ['trim']]} />
<input v-model={[val, 'name', ['trim']]} />

多个model

// template
<A v-model="foo" v-model:bar="bar" />

// tsx
<A v-models={[[foo], [bar, "bar"]]} />

四、插槽

(一·)、渲染插槽

  1. js的方式
// 默认插槽
<div>{slots.default()}</div>

// 具名插槽
<div>{slots.footer({ text: props.message })}</div>
  1. dom的形式
export default function common(props, ctx) {
    const children = ctx.slots.default()[0]
    function handleBack() {
        ctx.emit('back', 12)
    }
    return (
        <div className={commonCss.panelContainer}>
            <div className={commonCss.header}>
                <MyIcon name="return" size={16} style={{cursor: 'pointer'}} onClick={handleBack}></MyIcon>
            </div>
            <children></children>
        </div>
    )
}

(二)、传递插槽

// 默认插槽
<MyComponent>{() => 'hello'}</MyComponent>

// 具名插槽
<MyComponent>{{
  default: () => 'default slot',
  foo: () => <div>foo</div>,
  bar: () => [<span>one</span>, <span>two</span>]
}}</MyComponent>

也可以如下:

// 具名插槽
<MyComponent v-slots={{
  default: () => 'default slot',
  foo: () => <div>foo</div>,
  bar: () => [<span>one</span>, <span>two</span>]
}}></MyComponent>

(四)、作用域插槽

<MyComponent>{{
  default: ({ text }) => <p>{ text }</p>  
}}</MyComponent>

五、总结

模板语法(Template) JSX/TSX 语法 说明
v-if="show" {show && <div>内容</div>} 三元表达式也可:{show ? <div>显示</div> : <div>隐藏</div>}
v-show="show" <div v-show={show}>内容</div> 直接支持 v-show
v-for="item in list" {list.map(item => <div>{item}</div>)} 需加 key
@click.stop="handleClick" onClick={withModifiers(handleClick, ['stop'])} 修饰符用 withModifiers 包裹
v-model:visible="show" <el-popover v-model={[show, 'visible']}>

感谢您抽出宝贵的时间观看本文;本文是 Vue3 核心 API 系列的第 2 篇,后续会持续更新 computed、ref/reactive、生命周期等实战内容,同时正在整理「Vue3 完整项目实战小册」(包含从 0 到 1 开发小程序 / 管理系统的全流程),欢迎关注~

Vue3 状态管理库 Pinia 完整教程

作者 学以智用
2026年3月19日 09:50

你想系统学习 Vue3 官方推荐的状态管理库 Pinia,我会从核心概念、基础使用、模块化、异步操作到实战技巧,用最简单易懂的方式教你完全掌握。

一、核心概念

Pinia 是 Vue 官方新一代状态管理库,替代 Vuex,专为 Vue3 设计,同时兼容 Vue2,核心优势:

  • 语法简洁,无需 mutation(只有 state、getters、actions)
  • 天然支持 TypeScript
  • 模块化设计,无需嵌套模块
  • 体积更小,性能更高
  • 支持热更新、插件扩展

二、快速上手(步骤)

1. 安装 Pinia

# npm
npm install pinia

# yarn
yarn add pinia

# pnpm
pnpm add pinia

2. 在 main.js 全局注册

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

const app = createApp(App)
// 挂载 Pinia
app.use(createPinia())
app.mount('#app')

三、定义 Store(核心)

Store 是存储状态和业务逻辑的容器,推荐按功能模块化拆分(如 user、cart、setting)。

1. 创建 Store 示例

src/stores/ 目录下新建文件(如 user.js):

// src/stores/user.js
import { defineStore } from 'pinia'

// 第一个参数:store 唯一 ID(必须唯一)
// 第二个参数:配置对象
export const useUserStore = defineStore('user', {
  // 1. 状态:存储数据(类似 data)
  state: () => ({
    name: '张三',
    age: 20,
    token: ''
  }),

  // 2. 计算属性:派生状态(类似 computed,有缓存)
  getters: {
    // 自动接收 state 作为参数
    doubleAge: (state) => state.age * 2,
    // 也可以使用 this 访问整个 store
    getName: function() {
      return `我的名字:${this.name}`
    }
  },

  // 3. 方法:修改状态、异步请求(类似 methods)
  actions: {
    // 同步修改
    updateName(newName) {
      this.name = newName
    },
    // 异步修改(支持 async/await)
    async login(account, pwd) {
      // 模拟接口请求
      const res = await new Promise(resolve => {
        setTimeout(() => resolve({ token: 'abcd-1234' }), 1000)
      })
      this.token = res.token
      this.name = account
    }
  }
})

四、组件中使用 Store

1. 基础使用(读取/修改状态)

<template>
  <div>
    <p>姓名:{{ userStore.name }}</p>
    <p>年龄:{{ userStore.age }}</p>
    <p>双倍年龄:{{ userStore.doubleAge }}</p>
    <button @click="userStore.updateName('李四')">改名</button>
    <button @click="userStore.login('admin', '123456')">登录</button>
  </div>
</template>

<script setup>
// 导入定义好的 store
import { useUserStore } from './stores/user'

// 实例化 store
const userStore = useUserStore()
</script>

2. 解构 state(保持响应式)

直接解构会丢失响应式,必须用 storeToRefs

import { storeToRefs } from 'pinia'

// 正确写法:响应式解构
const { name, age, doubleAge } = storeToRefs(userStore)

// 注意:actions 不需要解构,直接用
const { updateName, login } = userStore

3. 批量修改 state

// 方式1:单个修改
userStore.name = '王五'

// 方式2:批量修改(推荐)
userStore.$patch({
  name: '赵六',
  age: 25
})

// 方式3:函数式批量修改(适合复杂逻辑)
userStore.$patch(state => {
  state.name = '孙七'
  state.age += 1
})

4. 重置 state 到初始值

userStore.$reset()

五、模块化与 Store 相互调用

Pinia 无需配置模块,直接导入其他 Store 即可使用

示例:cart 购物车 store 调用 user store

// src/stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'

export const useCartStore = defineStore('cart', {
  state: () => ({
    list: []
  }),
  actions: {
    addCart(goods) {
      const userStore = useUserStore()
      // 判断用户是否登录
      if (!userStore.token) {
        alert('请先登录')
        return
      }
      this.list.push(goods)
    }
  }
})

六、数据持久化(常用插件)

页面刷新后 Pinia 数据会丢失,使用 pinia-plugin-persistedstate 插件实现本地持久化。

1. 安装

pnpm add pinia-plugin-persistedstate

2. 全局注册

// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

3. 开启持久化

// 在 store 中添加 persist: true
export const useUserStore = defineStore('user', {
  state: () => ({ ... }),
  persist: true // 开启持久化(默认 localStorage)
})

七、完整实战总结

  1. 安装 + 全局注册 Pinia
  2. 按功能拆分 Store(user/cart 等)
  3. state 存数据、getters 做计算、actions 做修改/异步
  4. 组件中导入使用,storeToRefs 解构保持响应式
  5. 跨模块调用直接导入其他 Store
  6. 用持久化插件保存数据不丢失

总结

  1. Pinia 是 Vue3 首选状态库,无 mutation、语法极简
  2. 核心三部分:state(数据)、getters(计算)、actions(方法)
  3. 响应式解构必须用 storeToRefs
  4. 模块化天然支持,持久化插件一键配置

Vue-Vue Router核心原理+实战用法全解析

2026年3月18日 17:41

前言

无论是单页面应用(SPA)还是复杂的后台管理系统,路由(Router)都是其灵魂。它通过 URL 映射组件,实现了无刷新的页面切换。本文将从底层原生 API 出发,带你彻底弄懂 Vue Router 的运行机制。

一、 路由的本质:Hash vs History

前端路由的核心是:改变 URL,页面不刷新,但渲染不同的组件。 Vue Router 本质上是基于浏览器原生的 window.location.hashhistory API 实现的,通过监听 URL 变化,动态匹配路由规则并渲染对应组件,无需后端参与页面切换。

1. Hash 模式 (window.location.hash)

  • URL 特征:路径中携带# 符号,例如 http://xxx.com/#/homehttp://xxx.com/#/about
  • 底层依赖window.location.hash
  • 核心特性:URL 中 # 后的内容属于锚点定位,不会发送到服务器端,所有前端路由请求最终都会指向 域名/index.html,服务器只需返回首页文件即可。
  • 优势:无需额外配置服务器,刷新页面、直接访问子路由都不会出现 404 错误,兼容性极强。

2. History 模式 (window.history)

  • URL 特征:路径中无 # 符号,形态更简洁,例如 http://xxx.com/homehttp://xxx.com/about
  • 底层依赖:浏览器原生 history API
  • 核心坑点:当用户刷新页面、直接访问子路由时,浏览器会向服务器发送对应路径的 GET 请求(如请求 /home),如果服务器未配置路由指向,会直接返回 404 错误。
  • 解决方案:必须在 Nginx 等服务器中配置规则,将所有路由请求都指向项目入口 index.html,由前端路由接管匹配逻辑。
location / {
  root   /usr/share/nginx/html;
  index  index.html index.htm;
  # 关键:找不到资源时返回 index.html
  try_files $uri $uri/ /index.html; 
}

二、 底层原理实现

1. Hash 模式实现链路

  • 监听变化:基于 windowhashchange 事件,监听 URL 中 hash 值的变化。
  • 设置值:修改 location.hash手动修改路由路径。
  • 跳转:使用 location.assign()实现路由跳转。
  • 获取当前路径:通过 location.hreflocation.hash 解析。

2. History 模式实现链路

  • 监听变化:基于浏览器原生 popstate 事件,仅监听浏览器前进/后退操作触发的路由变化。

    ⚠️ 避坑点:调用 history.pushStatereplaceState 改变 URL 时,并不会触发 popstate。Vue Router 内部通过劫持这些方法手动触发了更新。

  • 操作记录

    • pushState(stateObj, title, url):添加历史记录。
    • replaceState(stateObj, title, url):替换当前记录。
  • 获取路径:基于 window.location.pathname获取纯路径部分。

  • 状态存储:通过 history.state 获取传给 pushState 的自定义对象。


三、 Vue 路由跳转实战

方法一:声明式导航 <router-link>

这是日常开发中最常用的方式,本质是对 <a> 标签的封装,默认无刷新跳转,语法简洁且支持路由参数传递。核心参数如下:

  • to(必传) :目标路由路径,支持字符串格式和对象格式

    • 字符串格式:<router-link to="/home">首页</router-link>
    • 对象格式:可搭配 name、query、params 实现精细化跳转
  • name:通过路由名称跳转(推荐,避免路径硬编码),示例::to="{ name: 'About' }"

  • query:传递查询参数,参数会拼接在 URL 中(刷新不丢失),示例::to="{ name: 'About', query: { name: 'test' } }",最终 URL:/about?name=test

  • params:传递动态路由参数,参数不会拼接在 URL(刷新会丢失),必须配合 name 使用,示例::to="{ name: 'About', params: { id: 123 } }"

    注意:若路由规则中未定义动态参数(如 :id),仅通过 name + params 传参,刷新页面后 params 会丢失;

    解决办法:在路由规则中添加 :id(必传)或 :id?(可选),例如 path: '/about/:id?'

方法二:编程式导航 useRouter

通过 useRouter 获取路由实例,用代码控制路由跳转,适合非点击触发的场景(如接口请求成功后跳转、条件判断跳转、定时器跳转等)

<script setup>
import { useRouter } from 'vue-router'
// 获取路由实例
const router = useRouter()

// 编程式跳转
const goToPage = () => {
  // 1. push 跳转(新增历史记录,可返回)
  router.push('/home')
  // 对象格式跳转
  router.push({ name: 'About', query: { name: 'test' } })

  // 2. replace 跳转(替换历史记录,不可返回)
  router.replace('/about')

  // 3. 路由前进/后退
  router.go(-1) // 后退一页
  router.back() // 后退一页(等价 go(-1))
  router.forward() // 前进一页(等价 go(1))
}
</script>

四、 Vue 路由监听三大方法

Vue 监听路由变化,本质是监听 route 对象(包含 path/params/query 等属性)的变化,触发自定义回调函数,常用于路由切换时更新数据、重置状态等场景.

1. 使用 watch + useRoute

通过 useRoute 获取当前路由对象,搭配 watch 监听器实现路由变化监听,支持立即执行、深度监听,适用性最广。

const route = useRoute();

watch(
  () => route.query,
  (newQuery) => {
    console.log('搜索参数变了:', newQuery);
  },
  { immediate: true, deep: true } // immediate 确保初始化时执行
);

2. 路由守卫 onBeforeRouteUpdate

Vue Router 提供的导航守卫,仅在组件复用时触发(例如 /detail/123/detail/456),路由跳转到其他组件时不会触发,适合列表页跳转详情页等场景。

  • 优点:不需要 watch 那么大的开销,专门针对参数更新。
  • 局限:离开该组件或首次进入时不触发。
<script setup>
import { onBeforeRouteUpdate } from 'vue-router'

// 组件复用时触发
onBeforeRouteUpdate((to, from) => {
  console.log('即将跳转至:', to.path)
  console.log('从:', from.path, '跳转而来')
  // 可在此处更新组件数据
})
</script>

3. 原生监听(底层方案)

直接监听浏览器原生路由事件,脱离 Vue Router API 实现监听,适合特殊定制场景,需注意事件解绑避免内存泄漏。

window.addEventListener('popstate', callback)


vue-router v5.x createRouter 是创建路由实例?

作者 米丘
2026年3月18日 13:05

vue-router 初始化方法 createRouter。

image.png

createRouter

1、做了什么?

  • createRouterMatcher 初始化路由匹配系统。
  • 初始化 URL 处理,设置 URL 查询参数的解析和序列化函数。
  • 初始化历史管理器,初始化路由历史管理(Hash/History 模式)。
  • 初始化导航守卫系统,创建全局导航守卫的回调队列,beforeGuards,beforeResolveGuardsafterGuards
  • 初始化路由状态,创建响应式的当前路由状态currentRoute和待处理路由pendingLocation
  • 浏览器寒假配置滚动行为。
  • 定义核心的路由管理、路由导航方法。

2、函数返回?

返回 Router 实例。

/**
 * Creates a Router instance that can be used by a Vue app.
 * 负责组装路由的所有核心能力(路由匹配、导航守卫、历史记录管理、滚动行为、URL 解析 / 生成等),
 * 最终返回一个可安装到 Vue 应用的 Router 实例
 * @param options - {@link RouterOptions}
 */
export function createRouter(options: RouterOptions): Router {
  // 创建路由匹配器:解析 routes 配置,生成匹配规则(核心)
  const matcher = createRouterMatcher(options.routes, options)

  // 初始化 URL 查询参数解析/序列化函数(默认/自定义)
  const parseQuery = options.parseQuery || originalParseQuery
  const stringifyQuery = options.stringifyQuery || originalStringifyQuery

  // 初始化历史管理器(Hash/History 模式),开发环境校验必传
  const routerHistory = options.history

  if (__DEV__ && !routerHistory)
    throw new Error(
      'Provide the "history" option when calling "createRouter()":' +
        ' https://router.vuejs.org/api/interfaces/RouterOptions.html#history'
    )

  // 初始化导航守卫队列(全局前置/解析后/后置守卫)
  const beforeGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
  const beforeResolveGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
  const afterGuards = useCallbacks<NavigationHookAfter>()

  // 初始化当前路由(响应式)和待处理路由
  const currentRoute = shallowRef<RouteLocationNormalizedLoaded>(
    START_LOCATION_NORMALIZED
  )
  // 待处理路由(当前导航目标),初始值为起始路由
  let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED

  // 滚动行为初始化:有自定义 scrollBehavior 时,禁用浏览器默认滚动恢复
  // leave the scrollRestoration if no scrollBehavior is provided
  if (isBrowser && options.scrollBehavior && 'scrollRestoration' in history) {
    history.scrollRestoration = 'manual'
  }

  const normalizeParams = applyToParams.bind(
    null,
    paramValue => '' + paramValue
  )
  // 遍历路由参数对象的所有值,对每个值应用指定的处理函数,并返回新的参数对象
  const encodeParams = applyToParams.bind(null, encodeParam)
  const decodeParams: (params: RouteParams | undefined) => RouteParams =
    // @ts-expect-error: intentionally avoid the type check
    applyToParams.bind(null, decode)

  let removeHistoryListener: undefined | null | (() => void)
  
  let readyHandlers = useCallbacks<_OnReadyCallback>()
  let errorListeners = useCallbacks<_ErrorListener>()
  let ready: boolean
  
  const go = (delta: number) => routerHistory.go(delta)

  let started: boolean | undefined
  const installedApps = new Set<App>()
  
    // NOTE: we need to cast router as Router because the experimental
  // data-loaders add many properties that aren't available here. We might want
  // to add them later on instead of having declare module in experimental
  const router = {
    currentRoute,
    listening: true, // 监听路由

    addRoute,
    removeRoute,
    clearRoutes: matcher.clearRoutes,
    hasRoute,
    getRoutes,
    resolve,
    options,

    push,
    replace,
    go,
    back: () => go(-1),
    forward: () => go(1),

    beforeEach: beforeGuards.add,
    beforeResolve: beforeResolveGuards.add,
    afterEach: afterGuards.add,

    onError: errorListeners.add,
    isReady,

    /**
     * Vue 应用集成(install 方法)
     * @param app
     */
    install(app: App) {
      // 注册全局组件 RouterLink 和 RouterView
      app.component('RouterLink', RouterLink)
      app.component('RouterView', RouterView)

      // 暴露 $router/$route 到全局
      app.config.globalProperties.$router = router as Router
      Object.defineProperty(app.config.globalProperties, '$route', {
        enumerable: true,
        get: () => unref(currentRoute),
      })

      // this initial navigation is only necessary on client, on server it doesn't
      // make sense because it will create an extra unnecessary navigation and could
      // lead to problems
      // 初始化首次导航(客户端)
      if (
        isBrowser &&
        // used for the initial navigation client side to avoid pushing
        // multiple times when the router is used in multiple apps
        !started &&
        currentRoute.value === START_LOCATION_NORMALIZED
      ) {
        // see above
        started = true
        push(routerHistory.location).catch(err => {
          if (__DEV__) warn('Unexpected error when starting the router:', err)
        })
      }

      const reactiveRoute = {} as RouteLocationNormalizedLoaded
      for (const key in START_LOCATION_NORMALIZED) {
        Object.defineProperty(reactiveRoute, key, {
          get: () => currentRoute.value[key as keyof RouteLocationNormalized],
          enumerable: true,
        })
      }

      // 提供路由注入(useRouter/useRoute)
      app.provide(routerKey, router as Router)
      app.provide(routeLocationKey, shallowReactive(reactiveRoute))
      app.provide(routerViewLocationKey, currentRoute)

      const unmountApp = app.unmount
      installedApps.add(app)

      // 应用卸载时清理
      app.unmount = function () {
        installedApps.delete(app)
        // the router is not attached to an app anymore
        if (installedApps.size < 1) {
          // invalidate the current navigation
          pendingLocation = START_LOCATION_NORMALIZED
          removeHistoryListener && removeHistoryListener()
          removeHistoryListener = null
          currentRoute.value = START_LOCATION_NORMALIZED
          started = false
          ready = false
        }
        unmountApp()
      }

      // TODO: this probably needs to be updated so it can be used by vue-termui
      if (
        (__DEV__ || __FEATURE_PROD_DEVTOOLS__) &&
        isBrowser &&
        !__STRIP_DEVTOOLS__
      ) {
        addDevtools(app, router as Router, matcher)
      }
    },
  } satisfies Pick<Router, Extract<keyof Router, string>>
  
  return router as Router
}

参数 options 有哪些属性?

/**
 * Options to initialize a {@link Router} instance.
 */
export interface RouterOptions extends EXPERIMENTAL_RouterOptions_Base {
  /**
   * Initial list of routes that should be added to the router.
   */
  routes: Readonly<RouteRecordRaw[]>
}
/**
 * Options to initialize a {@link Router} instance.
 */
export interface EXPERIMENTAL_RouterOptions_Base extends PathParserOptions {
  /**
   * History implementation used by the router. Most web applications should use
   * `createWebHistory` but it requires the server to be properly configured.
   * You can also use a _hash_ based history with `createWebHashHistory` that
   * does not require any configuration on the server but isn't handled at all
   * by search engines and does poorly on SEO.
   *
   * @example
   * ```js
   * createRouter({
   *   history: createWebHistory(),
   *   // other options...
   * })
   * ```
   */
  history: RouterHistory // 指定路由使用的「历史记录管理器」,决定路由模式(Hash/History)

  /**
   * Function to control scrolling when navigating between pages. Can return a
   * Promise to delay scrolling.
   *
   * @see {@link RouterScrollBehavior}.
   *
   * @example
   * ```js
   * function scrollBehavior(to, from, savedPosition) {
   *   // `to` and `from` are both route locations
   *   // `savedPosition` can be null if there isn't one
   * }
   * ```
   */
  scrollBehavior?: RouterScrollBehavior // 自定义路由切换时的页面滚动行为(如返回顶部、恢复滚动位置)

  /**
   * Custom implementation to parse a query. See its counterpart,
   * {@link EXPERIMENTAL_RouterOptions_Base.stringifyQuery}.
   *
   * @example
   * Let's say you want to use the [qs package](https://github.com/ljharb/qs)
   * to parse queries, you can provide both `parseQuery` and `stringifyQuery`:
   * ```js
   * import qs from 'qs'
   *
   * createRouter({
   *   // other options...
   *   parseQuery: qs.parse,
   *   stringifyQuery: qs.stringify,
   * })
   * ```
   */
  parseQuery?: typeof originalParseQuery // 将 URL 中的查询参数字符串(如 a=1&b=2)解析为对象({ a: '1', b: '2' })

  /**
   * Custom implementation to stringify a query object. Should not prepend a leading `?`.
   * {@link parseQuery} counterpart to handle query parsing.
   * 将查询参数对象({ a: '1', b: '2' })序列化为字符串(a=1&b=2),无需手动加 ?
   */

  stringifyQuery?: typeof originalStringifyQuery

  /**
   * Default class applied to active {@link RouterLink}. If none is provided,
   * `router-link-active` will be applied.
   * 设置 <RouterLink> 「部分匹配激活」时的默认类名(如 /home 匹配 /home/child)
   */
  linkActiveClass?: string

  /**
   * Default class applied to exact active {@link RouterLink}. If none is provided,
   * `router-link-exact-active` will be applied.
   * 设置 <RouterLink> 「精确匹配激活」时的默认类名(仅 /home 匹配 /home)
   */
  linkExactActiveClass?: string

  /**
   * Default class applied to non-active {@link RouterLink}. If none is provided,
   * `router-link-inactive` will be applied.
   * 预留配置,用于设置 <RouterLink> 「非激活状态」的默认类名,当前版本未启用
   */
  // linkInactiveClass?: string
}
/**
 * @internal
 */
export interface _PathParserOptions {
  /**
   * Makes the RegExp case-sensitive.
   * 控制路由路径匹配时是否区分大小写(影响生成的正则表达式是否添加 i 标志)
   * @defaultValue `false` false(不区分大小写,如 /Home 和 /home 视为同一路由)
   */
  sensitive?: boolean

  /**
   * Whether to disallow a trailing slash or not.
   * 控制是否严格匹配路径末尾的斜杠(/)
   * @defaultValue `false` false(允许末尾斜杠,如 /home 和 /home/ 视为同一路由)
   */
  strict?: boolean

  /**
   * Should the RegExp match from the beginning by prepending a `^` to it.
   * @internal
   * 控制生成的路径匹配正则是否添加 ^ 前缀(即是否从字符串开头开始匹配)
   * @defaultValue `true` true(必须从路径开头匹配,符合路由匹配的基本逻辑)
   */
  start?: boolean

  /**
   * Should the RegExp match until the end by appending a `$` to it.
   * 控制生成的路径匹配正则是否添加 $ 后缀(即是否完整匹配路径末尾)
   * @deprecated this option will alsways be `true` in the future. Open a discussion in vuejs/router if you need this to be `false`
   * 已废弃
   * @defaultValue `true`
   */
  end?: boolean
}
export type PathParserOptions = Pick<
  _PathParserOptions,
  'end' | 'sensitive' | 'strict'
>

routes 配置

export type RouteRecordRaw =
  | RouteRecordSingleView // 最基础的路由配置,对应「一个路径匹配一个组件」的场景,无嵌套子路由
  // 基础单视图路由 + 嵌套子路由(对应 <RouterView> 嵌套渲染)
  | RouteRecordSingleViewWithChildren
  // 一个路径匹配多个组件,对应 <RouterView name="xxx"> 命名视图
  | RouteRecordMultipleViews
  // 多视图路由 + 嵌套子路由,是 RouteRecordMultipleViews 的扩展
  | RouteRecordMultipleViewsWithChildren
  // 仅用于路由重定向,无组件 / 视图配置,匹配路径后跳转到目标路由
  | RouteRecordRedirect

1、RouteRecordSingleView

基础单视图,一个路径匹配一个组件。禁止components、children、redirect。

/**
 * Route Record defining one single component with the `component` option.
 */
export interface RouteRecordSingleView extends _RouteRecordBase {
  /**
   * Component to display when the URL matches this route.
   * 指定路由匹配时要渲染的单个组件,是单视图路由的核心标识
   */
  component: RawRouteComponent
  // 明确禁止在单视图路由中使用 components 字段(多视图路由的核心字段)
  components?: never
  // 明确禁止在单视图路由中使用 children 字段(嵌套路由的核心字段)
  children?: never
  // 明确禁止在单视图路由中使用 redirect 字段(重定向路由的核心字段)
  redirect?: never

  /**
   * Allow passing down params as props to the component rendered by `router-view`.
   * 控制是否将路由参数(params/query)作为 props 传递给路由组件,避免组件直接依赖 $route
   */
  props?: _RouteRecordProps
}
// TODO: could this be moved to matcher? YES, it's on the way
/**
 * Internal type for common properties among all kind of {@link RouteRecordRaw}.
 */
export interface _RouteRecordBase extends PathParserOptions {
  /**
   * Path of the record. Should start with `/` unless the record is the child of
   * another record.
   * 路由路径
   * @example `/users/:id` matches `/users/1` as well as `/users/posva`.
   */
  path: string

  /**
   * Where to redirect if the route is directly matched. The redirection happens
   * before any navigation guard and triggers a new navigation with the new
   * target location.
   * 路由重定向选项,用于定义路由跳转目标
   */
  redirect?: RouteRecordRedirectOption

  /**
   * Aliases for the record. Allows defining extra paths that will behave like a
   * copy of the record. Allows having paths shorthands like `/users/:id` and
   * `/u/:id`. All `alias` and `path` values must share the same params.
   * 路由别名数组,用于定义额外的路径
   */
  alias?: string | string[]

  /**
   * Name for the route record. Must be unique.
   * 路由名称,必须唯一
   */
  name?: RouteRecordNameGeneric

  /**
   * Before Enter guard specific to this record. Note `beforeEnter` has no
   * effect if the record has a `redirect` property.
   */
  beforeEnter?:
    | NavigationGuardWithThis<undefined>
    | NavigationGuardWithThis<undefined>[]

  /**
   * Arbitrary data attached to the record.
   * 路由元数据,用于存储自定义信息,如权限、标题等
   */
  meta?: RouteMeta

  /**
   * Array of nested routes.
   * 子路由数组,用于定义嵌套路由结构
   */
  children?: RouteRecordRaw[]

  /**
   * Allow passing down params as props to the component rendered by `router-view`.
   */
  props?: _RouteRecordProps | Record<string, _RouteRecordProps>
}

2、RouteRecordSingleViewWithChildren

单视图嵌套子路由。禁止配置components。

/**
 * Route Record defining one single component with a nested view. Differently
 * from {@link RouteRecordSingleView}, this record has children and allows a
 * `redirect` option.
 */
export interface RouteRecordSingleViewWithChildren extends _RouteRecordBase {
  /**
   * Component to display when the URL matches this route.
   * 指定父路由匹配时渲染的布局组件(需包含 <RouterView> 用于渲染子路由)
   */
  component?: RawRouteComponent | null | undefined
  // 与 RouteRecordSingleView 一致,禁止使用 components(多视图字段),保证父路由为「单视图布局」
  components?: never

  // 定义父路由下的嵌套子路由,是该接口的核心标识(区别于 RouteRecordSingleView)
  children: RouteRecordRaw[]

  /**
   * Allow passing down params as props to the component rendered by `router-view`.
   * 控制是否将父路由的参数传递给父布局组件(而非子路由组件)
   */
  props?: _RouteRecordProps
}

3、RouteRecordMultipleViews

多视图。禁止配置component、children、redirect。

/**
 * Route Record defining multiple named components with the `components` option.
 */
export interface RouteRecordMultipleViews extends _RouteRecordBase {
  /**
   * Components to display when the URL matches this route. Allow using named views.
   * 指定路由匹配时要渲染的多个命名组件,键为「视图名称」,值为「组件」,是多视图路由的核心标识
   * 示例  components: {
            default: () => import('./DashboardMain.vue'), // 对应 <RouterView>(默认视图)
            header: () => import('./DashboardHeader.vue'), // 对应 <RouterView name="header">
            sidebar: () => import('./DashboardSidebar.vue'), // 对应 <RouterView name="sidebar">
          },
   */
  components: Record<string, RawRouteComponent>
  component?: never // 明确禁止使用 component 字段(单视图路由的核心字段)
  // 禁止使用 children 字段,多视图 + 嵌套子路由需使用 RouteRecordMultipleViewsWithChildren 类型
  children?: never
  // 禁止使用 redirect 字段,重定向路由需使用 RouteRecordRedirect 类型
  redirect?: never

  /**
   * Allow passing down params as props to the component rendered by
   * `router-view`. Should be an object with the same keys as `components` or a
   * boolean to be applied to every component.
   * 控制是否将路由参数传递给每个命名视图组件,是单视图 props 字段的多视图扩展
   */
  props?: Record<string, _RouteRecordProps> | boolean
}

4、RouteRecordMultipleViewsWithChildren

多视图嵌套子路由。禁止配置component。

/**
 * Route Record defining multiple named components with the `components` option and children.
 */
export interface RouteRecordMultipleViewsWithChildren extends _RouteRecordBase {
  /**
   * Components to display when the URL matches this route. Allow using named views.
   * 指定父路由匹配时渲染的多命名视图布局组件(需包含多个 <RouterView name="xxx"> 用于渲染子路由);
   * 1、有布局组件:父路由渲染多视图布局(如 header + sidebar + main),子路由可覆盖 / 扩展父视图;
   * 2、无布局组件:父路由仅用于路径分组(如 /admin/* 下的多视图子路由,无可视化布局);
   */
  components?: Record<string, RawRouteComponent> | null | undefined
  // 与 RouteRecordMultipleViews 一致,禁止使用 component 字段(单视图路由的核心字段)
  component?: never

  // 定义父多视图路由下的嵌套子路由,是该接口的核心标识(区别于 RouteRecordMultipleViews)
  children: RouteRecordRaw[]

  /**
   * Allow passing down params as props to the component rendered by
   * `router-view`. Should be an object with the same keys as `components` or a
   * boolean to be applied to every component.
   * 控制是否将父路由的参数传递给父多视图组件(而非子路由组件)
   */
  props?: Record<string, _RouteRecordProps> | boolean
}

路由独享守卫 beforeEnter

beforeEnter 守卫 只在进入路由时触发,不会在 paramsquery 或 hash 改变时触发。

image.png

{
  path: '/dashboard',
  name: 'dashboard',
  component: () => import('@/views/dashboard/DashBoard.vue'),
  meta: {
    title: '看板',
    icon: 'dashboard',
    roles: ['admin', 'user']
  },
  // beforeEnter: (to, from) => {
  //   console.log('beforeEnter-to', to)
  //   console.log('beforeEnter-from', from)
  //   return true
  // },
  beforeEnter: [(to, from) => {
    console.log('beforeEnter-111to', to)
    console.log('beforeEnter-f111rom', from)
    return true
  }, (to, from) => {
    console.log('beforeEnter-222to', to)
    console.log('beforeEnter-222from', from)
    return true
  }]

},

Router 实例有哪些属性?

/**
 * Router instance.
 * 路由实例
 */
export interface Router extends EXPERIMENTAL_Router_Base<RouteRecordNormalized> {
  /**
   * Original options object passed to create the Router
   * 存储创建路由实例时传入的原始配置项
   */
  readonly options: RouterOptions

  /**
   * Add a new {@link RouteRecordRaw | route record} as the child of an existing route.
   * 动态路由方法
   * 重载 1:添加嵌套路由
   * 返回值:一个「移除该动态路由的函数」,调用后可删除本次添加的路由
   * @param parentName - Parent Route Record where `route` should be appended at
   * @param route - Route Record to add
   */
  addRoute(
    // NOTE: it could be `keyof RouteMap` but the point of dynamic routes is not knowing the routes at build
    parentName: NonNullable<RouteRecordNameGeneric>,
    route: RouteRecordRaw
  ): () => void
  /**
   * Add a new {@link RouteRecordRaw | route record} to the router.
   *
   * @param route - Route Record to add
   * 重载 2:添加顶级路由
   * 返回值:一个「移除该动态路由的函数」,调用后可删除本次添加的路由
   */
  addRoute(route: RouteRecordRaw): () => void

  /**
   * Remove an existing route by its name.
   *
   * @param name - Name of the route to remove 路由名称(非空),注意只能通过名称删除,不能通过路径
   * 根据路由名称删除已存在的路由(包括静态路由和动态添加的路由)
   * 
   */
  removeRoute(name: NonNullable<RouteRecordNameGeneric>): void

  /**
   * Delete all routes from the router.
   * 清空路由表中所有路由(包括静态路由和动态添加的路由)
   * 注意:清空后路由表为空,需重新调用 addRoute 添加路由,否则导航会失效
   */
  clearRoutes(): void
}
/**
 * Router base instance.
 *
 * @experimental This version is not stable, it's meant to replace {@link Router} in the future.
 */
export interface EXPERIMENTAL_Router_Base<TRecord> {
  // NOTE: for dynamic routing we need this
  // <TRouteRecordRaw, TRouteRecord>
  /**
   * Current {@link RouteLocationNormalized}
   * 存储当前激活的标准化路由信息(响应式)
   */
  readonly currentRoute: ShallowRef<RouteLocationNormalizedLoaded>

  /**
   * Allows turning off the listening of history events. This is a low level api for micro-frontend.
   * 控制是否监听浏览器历史事件,专为「微前端」场景设计
   */
  listening: boolean

  // TODO: deprecate in favor of getRoute(name) and add it
  /**
   * Checks if a route with a given name exists
   * 根据路由名称判断路由是否存在(静态 / 动态添加的路由均可检测)
   * @param name - Name of the route to check
   */
  hasRoute(name: NonNullable<RouteRecordNameGeneric>): boolean

  /**
   * Get a full list of all the {@link RouteRecord | route records}.
   * 返回路由表中所有标准化路由记录
   */
  getRoutes(): TRecord[]

  /**
   * Returns the {@link RouteLocation | normalized version} of a
   * {@link RouteLocationRaw | route location}. Also includes an `href` property
   * that includes any existing `base`. By default, the `currentLocation` used is
   * `router.currentRoute` and should only be overridden in advanced use cases.
   * 将原始路由地址(如字符串、对象)解析为标准化的路由对象(包含 href、fullPath 等)
   * @param to - Raw route location to resolve
   * @param currentLocation - Optional current location to resolve against
   */
  resolve<Name extends keyof RouteMap = keyof RouteMap>(
    to: RouteLocationAsRelativeTyped<RouteMap, Name>,
    // NOTE: This version doesn't work probably because it infers the type too early
    // | RouteLocationAsRelative<Name>
    currentLocation?: RouteLocationNormalizedLoaded
  ): RouteLocationResolved<Name>
  resolve(
    // not having the overload produces errors in RouterLink calls to router.resolve()
    to: RouteLocationAsString | RouteLocationAsRelative | RouteLocationAsPath,
    currentLocation?: RouteLocationNormalizedLoaded
  ): RouteLocationResolved

  /**
   * Programmatically navigate to a new URL by pushing an entry in the history
   * stack.
   * 通过「新增历史记录」实现无刷新导
   *
   * @param to - Route location to navigate to
   */
  push(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>

  /**
   * Programmatically navigate to a new URL by replacing the current entry in
   * the history stack.
   * 通过「替换当前历史记录」实现导航(对应 history.replaceState),无历史记录回溯
   *
   * @param to - Route location to navigate to
   */
  replace(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>

  /**
   * Go back in history if possible by calling `history.back()`. Equivalent to
   * `router.go(-1)`.
   * 历史记录回溯
   */
  back(): void

  /**
   * Go forward in history if possible by calling `history.forward()`.
   * Equivalent to `router.go(1)`.
   * 历史记录回溯
   */
  forward(): void

  /**
   * Allows you to move forward or backward through the history. Calls
   * `history.go()`.
   *
   * @param delta - The position in the history to which you want to move,
   * relative to the current page
   * 历史记录回溯
   */
  go(delta: number): void

  /**
   * Add a navigation guard that executes before any navigation. Returns a
   * function that removes the registered guard.
   *
   * 注册全局前置守卫,导航触发时最先执行(可拦截、重定向导航)
   * @param guard - navigation guard to add
   */
  beforeEach(guard: NavigationGuardWithThis<undefined>): () => void

  /**
   * Add a navigation guard that executes before navigation is about to be
   * resolved. At this state all component have been fetched and other
   * navigation guards have been successful. Returns a function that removes the
   * registered guard.
   *
   * @param guard - navigation guard to add
   * @returns a function that removes the registered guard
   * 在所有组件内守卫、异步路由组件解析完成后,导航确认前执行
   * @example
   * ```js
   * router.beforeResolve(to => {
   *   if (to.meta.requiresAuth && !isAuthenticated) return false
   * })
   * ```
   *
   */
  beforeResolve(guard: _NavigationGuardResolved): () => void

  /**
   * Add a navigation hook that is executed after every navigation. Returns a
   * function that removes the registered hook.
   *
   * 导航完成后(成功 / 失败均执行),无法拦截导航
   *
   * @param guard - navigation hook to add
   * @returns a function that removes the registered hook
   *
   * @example
   * ```js
   * router.afterEach((to, from, failure) => {
   *   if (isNavigationFailure(failure)) {
   *     console.log('failed navigation', failure)
   *   }
   * })
   * ```
   */
  afterEach(guard: NavigationHookAfter): () => void

  /**
   * Adds an error handler that is called every time a non caught error happens
   * during navigation. This includes errors thrown synchronously and
   * asynchronously, errors returned or passed to `next` in any navigation
   * guard, and errors occurred when trying to resolve an async component that
   * is required to render a route.
   * 注册导航错误监听器,捕获导航过程中的所有未处理错误
   *
   * @param handler - error handler to register
   */
  onError(handler: _ErrorListener): () => void

  /**
   * Returns a Promise that resolves when the router has completed the initial
   * navigation, which means it has resolved all async enter hooks and async
   * components that are associated with the initial route. If the initial
   * navigation already happened, the promise resolves immediately.
   *
   * This is useful in server-side rendering to ensure consistent output on both
   * the server and the client. Note that on server side, you need to manually
   * push the initial location while on client side, the router automatically
   * picks it up from the URL.
   */
  isReady(): Promise<void> // 等待初始导航完成

  /**
   * Called automatically by `app.use(router)`. Should not be called manually by
   * the user. This will trigger the initial navigation when on client side.
   * 安装路由到 Vue 应用
   * 由 app.use(router) 自动调用,完成路由的初始化(注册全局组件、注入路由实例、触发初始导航)
   * @internal
   * @param app - Application that uses the router
   */
  install(app: App): void
}

实例方法 router.replace

  function replace(to: RouteLocationRaw) {
    return push(assign(locationAsObject(to), { replace: true }))
  }

实例方法 router.push

  function push(to: RouteLocationRaw) {
    return pushWithRedirect(to)
  }

pushWithRedirect

  /**
   * 负责处理「路由跳转 + 重定向 + 守卫执行 + 历史记录更新 + 错误处理」的全流程
   * @param to 目标路由位置(可以是字符串路径、命名路由对象或路径对象)
   * @param redirectedFrom 重定向来源路由位置(可选)
   * @returns 导航失败原因、成功时无返回值或 undefined
   */
  function pushWithRedirect(
    to: RouteLocationRaw | RouteLocation,
    redirectedFrom?: RouteLocation
  ): Promise<NavigationFailure | void | undefined> {

    // 解析目标路由为标准化 RouteLocation 对象
    const targetLocation: RouteLocation = (pendingLocation = resolve(to))
    const from = currentRoute.value // 获取当前路由(响应式的 currentRoute)

    // 获取历史记录状态(state)
    const data: HistoryState | undefined = (to as RouteLocationOptions).state
    // 获取强制跳转标志(force)
    const force: boolean | undefined = (to as RouteLocationOptions).force
    // to could be a string where `replace` is a function
    // 获取替换标志(replace)
    const replace = (to as RouteLocationOptions).replace === true

    // 检查目标路由是否配置了 redirect,返回重定向后的路由
    const shouldRedirect = handleRedirectRecord(targetLocation, from)

    // 若存在重定向,递归调用 pushWithRedirect 处理重定向后的路由
    if (shouldRedirect)
      return pushWithRedirect(
        // 合并重定向路由与原配置
        assign(locationAsObject(shouldRedirect), {
          state:
            typeof shouldRedirect === 'object'
              ? assign({}, data, shouldRedirect.state)
              : data,
          force,
          replace,
        }),
        // keep original redirectedFrom if it exists
        redirectedFrom || targetLocation
      )

    // if it was a redirect we already called `pushWithRedirect` above
    const toLocation = targetLocation as RouteLocationNormalized // 标准化目标路由

    toLocation.redirectedFrom = redirectedFrom // 标记重定向来源

    let failure: NavigationFailure | void | undefined // 声明导航失败变量

    // 非强制跳转 + 路由完全相同 → 生成重复跳转错误
    if (!force && isSameRouteLocation(stringifyQuery, from, targetLocation)) {
      failure = createRouterError<NavigationFailure>(
        ErrorTypes.NAVIGATION_DUPLICATED,
        {
          to: toLocation,
          from,
        }
      )
      // trigger scroll to allow scrolling to the same anchor
      // 即使重复跳转,仍处理滚动(如锚点 #top)
      handleScroll(
        from,
        from,
        // this is a push, the only way for it to be triggered from a
        // history.listen is with a redirect, which makes it become a push
        true, // push导航 
        // This cannot be the first navigation because the initial location
        // cannot be manually navigated to
        false // 非首次导航,初始路由不能手动跳转
      )
    }

    // 有失败则返回 resolved 的 failure,否则调用 navigate 执行真正的导航
    return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
      .catch((error: NavigationFailure | NavigationRedirectError) =>
        isNavigationFailure(error)
          ? // navigation redirects still mark the router as ready
          // 导航守卫重定向 → 仅返回错误,不标记 ready
            isNavigationFailure(error, ErrorTypes.NAVIGATION_GUARD_REDIRECT)
            ? error
            // 其他导航失败 → 标记 router 为 ready 并返回错误
            : markAsReady(error) // also returns the error
          : // reject any unknown error
          // 未知错误 → 触发全局错误并抛出
            triggerError(error, toLocation, from)
      )
      .then((failure: NavigationFailure | NavigationRedirectError | void) => {
        if (failure) {
          if (
            isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)
          ) {
            if (
              __DEV__ &&
              // we are redirecting to the same location we were already at
              // 开发环境:检测无限重定向(超过30次)并报警
              isSameRouteLocation(
                stringifyQuery,
                resolve(failure.to),
                toLocation
              ) &&
              // and we have done it a couple of times
              redirectedFrom &&
              // @ts-expect-error: added only in dev
              (redirectedFrom._count = redirectedFrom._count
                ? // @ts-expect-error
                  redirectedFrom._count + 1
                : 1) > 30
            ) {
              warn(
                `Detected a possibly infinite redirection in a navigation guard when going from "${from.fullPath}" to "${toLocation.fullPath}". Aborting to avoid a Stack Overflow.\n Are you always returning a new location within a navigation guard? That would lead to this error. Only return when redirecting or aborting, that should fix this. This might break in production if not fixed.`
              )
              return Promise.reject(
                new Error('Infinite redirect in navigation guard')
              )
            }

            return pushWithRedirect(
              // keep options
              assign(
                {
                  // preserve an existing replacement but allow the redirect to override it
                  replace,
                },
                locationAsObject(failure.to),
                {
                  state:
                    typeof failure.to === 'object'
                      ? assign({}, data, failure.to.state)
                      : data,
                  force,
                }
              ),
              // preserve the original redirectedFrom if any
              redirectedFrom || toLocation
            )
          }
        } else {
          // if we fail we don't finalize the navigation
          // 导航成功 → 最终化导航(更新历史记录/滚动/路由状态)
          failure = finalizeNavigation(
            toLocation as RouteLocationNormalizedLoaded,
            from,
            true,
            replace,
            data
          )
        }
        // 触发 afterEach 后置钩子
        triggerAfterEach(
          toLocation as RouteLocationNormalizedLoaded,
          from,
          failure
        )
        return failure
      })
  }

当待处理路由 与 当前路由完全一致,会出现以下问题

image.png

image.png

image.png

handleRedirectRecord

  /**
   * 「解析目标路由匹配记录中最后一条的 redirect 配置
   *  →标准化重定向目标格式→校验重定向合法性→合并原路由的 query/hash 等参数→返回最终的重定向目标」
   * @param to 目标路由对象
   * @param from 来源路由对象
   * @returns 
   */
  function handleRedirectRecord(
    to: RouteLocation,
    from: RouteLocationNormalizedLoaded
  ): RouteLocationRaw | void {

    const lastMatched = to.matched[to.matched.length - 1] // 获取最后一条匹配记录
    
    if (lastMatched && lastMatched.redirect) {
      const { redirect } = lastMatched // 获取 redirect 配置

      // 解析 redirect,目标重定向位置
      let newTargetLocation =
        typeof redirect === 'function' ? redirect(to, from) : redirect

      // 标准化字符串格式的 redirect → 对象格式
      if (typeof newTargetLocation === 'string') {
        newTargetLocation =
          // 字符串含 ?/# → 解析为完整对象(包含 query/hash)
          newTargetLocation.includes('?') || newTargetLocation.includes('#')
            ? (newTargetLocation = locationAsObject(newTargetLocation))
            : // force empty params
              { path: newTargetLocation }

        // @ts-expect-error: force empty params when a string is passed to let
        // the router parse them again
        // 强制清空 params,避免原路由 params 污染重定向目标
        newTargetLocation.params = {}
      }

      if (
        __DEV__ &&
        newTargetLocation.path == null &&
        !('name' in newTargetLocation)
      ) {
        warn(
          `Invalid redirect found:\n${JSON.stringify(
            newTargetLocation,
            null,
            2
          )}\n when navigating to "${
            to.fullPath
          }". A redirect must contain a name or path. This will break in production.`
        )
        throw new Error('Invalid redirect')
      }

      return assign(
        {
          query: to.query, // 继承原路由的 query 参数
          hash: to.hash, // 继承原路由的 hash 锚点
          // avoid transferring params if the redirect has a path
          // 重定向目标有 path → 清空 params;无 path(用 name 跳转)→ 继承原 params
          params: newTargetLocation.path != null ? {} : to.params,
        },
        newTargetLocation
      )
    }
  }

image.png

handleScroll

  // Scroll behavior
  function handleScroll(
    to: RouteLocationNormalizedLoaded, // 目标路由
    from: RouteLocationNormalizedLoaded, // 来源路由
    isPush: boolean, // 是否为 push 导航
    isFirstNavigation: boolean // 是否是应用首次导航(如页面初始化时的路由)
  ): // the return is not meant to be used
  Promise<unknown> {
  
    const { scrollBehavior } = options
    // 非浏览器环境(如SSR) 或 未配置 scrollBehavior → 直接返回成功 Promise
    if (!isBrowser || !scrollBehavior) return Promise.resolve()

    // 计算初始滚动位置(scrollPosition)
    const scrollPosition: _ScrollPositionNormalized | null =
      // 非 push 跳转(replace/后退)→ 读取保存的滚动位置
      (!isPush && getSavedScrollPosition(getScrollKey(to.fullPath, 0))) ||
      // 首次导航 或 非 push 跳转 → 读取 history.state 中的滚动位置
      ((isFirstNavigation || !isPush) &&
        (history.state as HistoryState) &&
        history.state.scroll) ||
      null // 其他情况 → 无滚动位置

    // 等待 DOM 更新完成后再执行滚动(路由跳转后组件渲染需要时间,避免滚动到未渲染的元素)
    return nextTick()
      // 调用用户配置的 scrollBehavior,获取目标滚动位置
      .then(() => scrollBehavior(to, from, scrollPosition))
      // 若返回了滚动位置,执行实际的滚动操作
      .then(position => position && scrollToPosition(position))
      // 捕获滚动过程中的错误,触发全局错误处理
      .catch(err => triggerError(err, to, from))
  }

scrollToPosition

最终调用原生 API window.scrollTo 实现。

export function scrollToPosition(position: ScrollPosition): void {
  let scrollToOptions: ScrollPositionCoordinates

  // 元素锚点型(包含 el 字段)
  if ('el' in position) {
    const positionEl = position.el
    const isIdSelector =
      typeof positionEl === 'string' && positionEl.startsWith('#')
    /**
     * `id`s can accept pretty much any characters, including CSS combinators
     * like `>` or `~`. It's still possible to retrieve elements using
     * `document.getElementById('~')` but it needs to be escaped when using
     * `document.querySelector('#\\~')` for it to be valid. The only
     * requirements for `id`s are them to be unique on the page and to not be
     * empty (`id=""`). Because of that, when passing an id selector, it should
     * be properly escaped for it to work with `querySelector`. We could check
     * for the id selector to be simple (no CSS combinators `+ >~`) but that
     * would make things inconsistent since they are valid characters for an
     * `id` but would need to be escaped when using `querySelector`, breaking
     * their usage and ending up in no selector returned. Selectors need to be
     * escaped:
     *
     * - `#1-thing` becomes `#\31 -thing`
     * - `#with~symbols` becomes `#with\\~symbols`
     *
     * - More information about  the topic can be found at
     *   https://mathiasbynens.be/notes/html5-id-class.
     * - Practical example: https://mathiasbynens.be/demo/html5-id
     */
    if (__DEV__ && typeof position.el === 'string') {
      // 场景1:是 ID 选择器但对应元素不存在,或不是 ID 选择器
      if (!isIdSelector || !document.getElementById(position.el.slice(1))) {
        try {
          const foundEl = document.querySelector(position.el)
          // 场景1.1:是 ID 选择器但通过 querySelector 找到了元素 → 警告(建议用 getElementById)
          if (isIdSelector && foundEl) {
            warn(
              `The selector "${position.el}" should be passed as "el: document.querySelector('${position.el}')" because it starts with "#".`
            )
            // return to avoid other warnings
            return
          }
        } catch (err) {
           // 场景1.2:选择器语法错误 → 警告(提示转义字符)
          warn(
            `The selector "${position.el}" is invalid. If you are using an id selector, make sure to escape it. You can find more information about escaping characters in selectors at https://mathiasbynens.be/notes/css-escapes or use CSS.escape (https://developer.mozilla.org/en-US/docs/Web/API/CSS/escape).`
          )
          // return to avoid other warnings
          return
        }
      }
    }

    // 查找目标 DOM 元素
    const el =
      typeof positionEl === 'string'
        ? isIdSelector
          ? document.getElementById(positionEl.slice(1)) // ID 选择器:直接用 getElementById
          : document.querySelector(positionEl)  // 其他选择器:用 querySelector
        : positionEl // 非字符串:直接使用传入的 HTMLElement

    // 元素不存在 → 开发环境警告并返回
    if (!el) {
      __DEV__ &&
        warn(
          `Couldn't find element using selector "${position.el}" returned by scrollBehavior.`
        )
      return
    }
    // 计算元素的滚动坐标
    scrollToOptions = getElementPosition(el, position)

    // 坐标型(直接使用)
  } else {
    scrollToOptions = position
  }

  // 浏览器支持平滑滚动(scrollBehavior API)
  // 判断浏览器是否支持 window.scrollTo 的配置项(如 { behavior: 'smooth' })
  if ('scrollBehavior' in document.documentElement.style)
    window.scrollTo(scrollToOptions)

  // 不支持平滑滚动 → 降级使用基础 scrollTo
  else {
    window.scrollTo(
      scrollToOptions.left != null ? scrollToOptions.left : window.scrollX,
      scrollToOptions.top != null ? scrollToOptions.top : window.scrollY
    )
  }
}

finalizeNavigation

  /**
   * - Cleans up any navigation guards
   * - Changes the url if necessary
   * - Calls the scrollBehavior
   */
  /**
   * 导航最终化
   * @param toLocation 目标路由
   * @param from 当前路由
   * @param isPush 是否为 push 导航
   * @param replace 是否为 replace 导航
   * @param data 导航状态数据
   * @returns
   */
  function finalizeNavigation(
    toLocation: RouteLocationNormalizedLoaded,
    from: RouteLocationNormalizedLoaded,
    isPush: boolean,
    replace?: boolean,
    data?: HistoryState
  ): NavigationFailure | void {
    // a more recent navigation took place
    // 校验导航是否被取消(并发导航冲突)
    const error = checkCanceledNavigation(toLocation, from)
    if (error) return error

    // only consider as push if it's not the first navigation
    // 判断是否为首次导航
    const isFirstNavigation = from === START_LOCATION_NORMALIZED
    const state: Partial<HistoryState> | null = !isBrowser ? {} : history.state

    // change URL only if the user did a push/replace and if it's not the initial navigation because
    // it's just reflecting the url
    // 仅在「主动 push 跳转」时更新 URL
    if (isPush) {
      // on the initial navigation, we want to reuse the scroll position from
      // history state if it exists
      // replace 模式 或 首次导航 → 使用 replaceState 更新 URL
      if (replace || isFirstNavigation)
        routerHistory.replace(
          toLocation.fullPath,
          assign(
            {
              scroll: isFirstNavigation && state && state.scroll,
            },
            data
          )
        )
        // 普通 push 跳转 → 使用 pushState 新增历史记录
      else routerHistory.push(toLocation.fullPath, data)
    }

    // accept current navigation
    // 更新响应式的当前路由 → 触发组件重新渲染
    currentRoute.value = toLocation 
    handleScroll(toLocation, from, isPush, isFirstNavigation) // 触发滚动

    markAsReady() // 标记就绪
  }

实例方法 router.resolve

router.resolve 是 Vue Router 提供的路由地址解析 API,用于将任意格式的路由地址(字符串 / 对象 / 命名路由)解析为标准化的 RouteLocationResolved 对象。

  /**
   * 路由地址解析器
   * @param rawLocation 原始路由地址(字符串或对象)
   * @param currentLocation 当前路由状态(可选)
   * @returns 解析后的路由地址对象
   */
  function resolve(
    rawLocation: RouteLocationRaw,
    currentLocation?: RouteLocationNormalizedLoaded
  ): RouteLocationResolved {
    // const resolve: Router['resolve'] = (rawLocation: RouteLocationRaw, currentLocation) => {
    // const objectLocation = routerLocationAsObject(rawLocation)
    // we create a copy to modify it later
    currentLocation = assign({}, currentLocation || currentRoute.value)

    // 解析字符串路由地址(包含 query/hash)
    if (typeof rawLocation === 'string') {
      const locationNormalized = parseURL(
        parseQuery,
        rawLocation,
        currentLocation.path
      )
      const matchedRoute = matcher.resolve(
        { path: locationNormalized.path },
        currentLocation
      )

      const href = routerHistory.createHref(locationNormalized.fullPath)
      if (__DEV__) {
        if (href.startsWith('//'))
          warn(
            `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.`
          )
        else if (!matchedRoute.matched.length) {
          warn(`No match found for location with path "${rawLocation}"`)
        }
      }

      // locationNormalized is always a new object
      return assign(locationNormalized, matchedRoute, {
        params: decodeParams(matchedRoute.params),
        hash: decode(locationNormalized.hash),
        redirectedFrom: undefined,
        href,
      })
    }

    // 校验 rawLocation 是否为合法的路由对象(包含 path/name 至少其一)
    if (__DEV__ && !isRouteLocation(rawLocation)) {
      warn(
        `router.resolve() was passed an invalid location. This will fail in production.\n- Location:`,
        rawLocation
      )
      return resolve({})
    }

    let matcherLocation: MatcherLocationRaw

    // path could be relative in object as well
    // 解析对象路由地址(包含 path/params/query/hash)
    // 含 path 的对象路由
    if (rawLocation.path != null) {
      // 开发环境警告:path 与 params 混用(params 会被忽略)
      // path 与 params 不兼容:通过 path 跳转时,params 会被忽略(因 path 已包含参数,如 /user/1)
      if (
        __DEV__ &&
        'params' in rawLocation &&
        !('name' in rawLocation) &&
        // @ts-expect-error: the type is never
        Object.keys(rawLocation.params).length
      ) {
        warn(
          `Path "${rawLocation.path}" was passed with params but they will be ignored. Use a named route alongside params instead.`
        )
      }
      matcherLocation = assign({}, rawLocation, {
        path: parseURL(parseQuery, rawLocation.path, currentLocation.path).path,
      })

      // 解析命名路由地址(包含 name/params)
    } else {
      // remove any nullish param
      const targetParams = assign({}, rawLocation.params)
      for (const key in targetParams) {
         // 移除 null/undefined 的 params(避免匹配错误)
        if (targetParams[key] == null) {
          delete targetParams[key]
        }
      }
      // pass encoded values to the matcher, so it can produce encoded path and fullPath
      matcherLocation = assign({}, rawLocation, {
        params: encodeParams(targetParams),
      })
      // current location params are decoded, we need to encode them in case the
      // matcher merges the params
      currentLocation.params = encodeParams(currentLocation.params)
    }

    const matchedRoute = matcher.resolve(matcherLocation, currentLocation)
    const hash = rawLocation.hash || ''

    // 开发环境警告:hash 未以 # 开头
    if (__DEV__ && hash && !hash.startsWith('#')) {
      warn(
        `A \`hash\` should always start with the character "#". Replace "${hash}" with "#${hash}".`
      )
    }

    // the matcher might have merged current location params, so
    // we need to run the decoding again
    matchedRoute.params = normalizeParams(decodeParams(matchedRoute.params))

    // 生成 fullPath(合并 path/query/hash)
    const fullPath = stringifyURL(
      stringifyQuery,
      assign({}, rawLocation, {
        hash: encodeHash(hash),
        path: matchedRoute.path,
      })
    )

    const href = routerHistory.createHref(fullPath)
    if (__DEV__) {
      if (href.startsWith('//')) {
        warn(
          `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.`
        )
      } else if (!matchedRoute.matched.length) {
        warn(
          `No match found for location with path "${
            rawLocation.path != null ? rawLocation.path : rawLocation
          }"`
        )
      }
    }

    return assign(
      {
        fullPath,
        // keep the hash encoded so fullPath is effectively path + encodedQuery +
        // hash
        hash,
        query:
          // if the user is using a custom query lib like qs, we might have
          // nested objects, so we keep the query as is, meaning it can contain
          // numbers at `$route.query`, but at the point, the user will have to
          // use their own type anyway.
          // https://github.com/vuejs/router/issues/328#issuecomment-649481567
          stringifyQuery === originalStringifyQuery
            ? normalizeQuery(rawLocation.query)
            : ((rawLocation.query || {}) as LocationQuery),
      },
      matchedRoute,
      {
        redirectedFrom: undefined,
        href,
      }
    )
  }
{
  path: '/dashboard',
  name: 'dashboard',
  component: () => import('@/views/dashboard/DashBoard.vue'),
  meta: {
    title: '看板',
    icon: 'dashboard',
    roles: ['admin', 'user']
  }
}

router.resolve 支持哪些输入格式?

  • 字符串格式(含绝对 / 相对路径、query/hash)。
  • 对象格式(path 模式),path 模式下传入 params 会被忽略(开发环境会警告)。
  • 对象格式(命名路由模式)。
// 解析 path 模式
console.log('router.resolve', router.resolve({
  path: '/dashboard'
}))

// 解析命名路由
console.log('router.resolve', router.resolve({
  name: 'dashboard'
}))

// 解析路径
console.log('router.resolve', router.resolve('/dashboard'))

image.png

实例方法 addRoute

  /**
   * 新增路由(支持嵌套)
   * 格式 1:addRoute(父路由名称, 子路由配置)
   * 格式 2:addRoute(路由配置)
   * @param parentOrRoute 父路由记录名或路由记录对象
   * @param route 子路由记录(可选)
   * @returns 移除路由的函数
   */
  function addRoute(
    parentOrRoute: NonNullable<RouteRecordNameGeneric> | RouteRecordRaw,
    route?: RouteRecordRaw
  ) {
    let parent: Parameters<(typeof matcher)['addRoute']>[1] | undefined
    let record: RouteRecordRaw

    // 判断第一个参数是否为「路由名称」(而非路由配置对象)
    if (isRouteName(parentOrRoute)) {
      // 根据路由名称从底层匹配器中获取对应的「路由记录匹配器」
      parent = matcher.getRecordMatcher(parentOrRoute)
      if (__DEV__ && !parent) {
        warn(
          `Parent route "${String(parentOrRoute)}" not found when adding child route`,
          route
        )
      }
      record = route!
    } else {
      record = parentOrRoute
    }

    return matcher.addRoute(record, parent)
  }

实例方法 removeRoute

  /**
   * 删除路由(根据路由记录名)
   * @param name 路由记录名称
   */
  function removeRoute(name: NonNullable<RouteRecordNameGeneric>) {
    const recordMatcher = matcher.getRecordMatcher(name)
    if (recordMatcher) {
      matcher.removeRoute(recordMatcher)
    } else if (__DEV__) {
      warn(`Cannot remove non-existent route "${String(name)}"`)
    }
  }

实例方法 getRoutes

  /**
   * 获取所有路由记录
   * @returns
   */
  function getRoutes() {
    return matcher.getRoutes().map(routeMatcher => routeMatcher.record)
  }

实例方法 hasRoute

  /**
   * 判断路由是否存在
   * @param name
   * @returns
   */
  function hasRoute(name: NonNullable<RouteRecordNameGeneric>): boolean {
    return !!matcher.getRecordMatcher(name)
  }

vue-router 是如何安装的?

router 实例的 install 是一个函数,vue 利用 vue 实例 app app.use(router) 引入 vue-router 。

image.png

vue-router 全局路由守卫有哪些?

image.png

beforeEach(guard: NavigationGuardWithThis<undefined>): () => void
beforeResolve(guard: _NavigationGuardResolved): () => void
afterEach(guard: NavigationHookAfter): () => void

v5 版本,已废弃 next() 写法,建议使用 return 返回替代。

// 已废弃写法
// 全局前置守卫
router.beforeEach((to, from, next) => {
  console.log('router.beforeEach-to', to)
  console.log('router.beforeEach-from', from)
  next()
})

// 全局解析守卫
router.beforeResolve((to, from, next) => {
  console.log('router.beforeResolve-to', to)
  console.log('router.beforeResolve-from', from)
  next()
})

image.png

// 建议写法
// 全局前置守卫
router.beforeEach((to, from) => {
  console.log('router.beforeEach-to', to)
  console.log('router.beforeEach-from', from)
  return true
})

// 全局解析守卫
router.beforeResolve((to, from) => {
  console.log('router.beforeResolve-to', to)
  console.log('router.beforeResolve-from', from)
  return true
})
export interface NavigationGuardWithThis<T> {
  (
    this: T,
    to: RouteLocationNormalized, // 目标路由对象
    from: RouteLocationNormalizedLoaded, // 来源路由对象
    /**
     * @deprecated Return a value from the guard instead of calling `next(value)`.
     * The callback will be removed in a future version of Vue Router.
     * 未来版本将移除对 `next(value)` 的调用,建议直接返回值。
     */
    next: NavigationGuardNext // 导航守卫回调函数
  ): _Awaitable<NavigationGuardReturn>
}

export interface _NavigationGuardResolved {
  (
    this: undefined,
    to: RouteLocationNormalizedLoaded,
    from: RouteLocationNormalizedLoaded,
    /**
     * @deprecated Return a value from the guard instead of calling `next(value)`.
     * The callback will be removed in a future version of Vue Router.
     */
    next: NavigationGuardNext
  ): _Awaitable<NavigationGuardReturn>
}

export interface NavigationHookAfter {
  (
    to: RouteLocationNormalizedLoaded,
    from: RouteLocationNormalizedLoaded,
    failure?: NavigationFailure | void
  ): unknown
}

router.push 接收参数的 3 种方式

/**
 * Route location that can be passed to `router.push()` and other user-facing APIs.
 */
export type RouteLocationRaw<Name extends keyof RouteMap = keyof RouteMap> =
  RouteMapGeneric extends RouteMap
    ?
        | RouteLocationAsString // 字符串路径(如 "/home")
        | RouteLocationAsRelativeGeneric // 命名路由泛型对象(如 { name: 'Home' })
        | RouteLocationAsPathGeneric  // 路径对象泛型(如 { path: '/home' })
    : // 强类型约束(开启 TS 强校验)
        | _LiteralUnion<RouteLocationAsStringTypedList<RouteMap>[Name], string>
        | RouteLocationAsRelativeTypedList<RouteMap>[Name]
        | RouteLocationAsPathTypedList<RouteMap>[Name]
const handleClick = () => {
  // 命名路由
  router.push({
    name: "user-list",
  });
};

const handleClick2 = () => {
  // 对象路由(path模式)
  router.push({
    path: "/user/123",
  });
};

const handleClick3 = () => {
  // 字符路由
  router.push("/data-view");
};

最后

  1. 源码阅读:github.com/hannah-lin-…

《Vue3 watch详情:deep/immediate/flush/once 全用法 + 踩坑总结》

作者 cmd
2026年3月18日 12:13

本文全面解析 Vue3 watch 所有用法,包含监听基础类型、引用类型、多个数据源、停止监听、深度监听、新旧值获取、与 watchEffect 区别,适合前端开发日常使用与面试准备。

《Vue3 watch详情:deep/immediate/flush/once 全用法 + 踩坑总结》

1. API介绍

watch(WatcherSource, Callback, [WatchOptions])

type WatcherSource<T> = Ref<T> | (() => T) 

interface WatchOptions extends WatchEffectOptions {
    deep?: boolean // 默认:false 
    immediate?: boolean // 默认:false 
    flush?: string // 默认:'pre'
}

参数说明:

WatcherSource: 用于指定要侦听的响应式数据源。侦听器数据源可以是返回值的 getter 函数,可以直接 是 ref reactive

callback : 执行的回调函数,可依次接受 newValue , oldValue 作为参数。

watchOptions: deep immediate flush once(3.4新增) 可选

  • 当需要对响应式对象进行深度监听时,设置 deep: true

  • 默认情况下watch是惰性的,当我们设置 immediate: true 时,watch会在初始化时立即执行回调函数

  • flush 选项可以更好地控制回调的时间。它可设置为 pre、post 或 sync

    • 默认值是 pre,指定的回调应该在DOM渲染前被调用。
    • post 值是可以用来将回调推迟到DOM渲染之后的。如果回调需要通过 $refs 访问更新的 DOM 或子组件,那么则使用该值。
    • 如果 flush 被设置为 sync,一旦值发生了变化,回调将被同步调用(少用,影响性能)。
  • once: true : 一次性侦听器;只生效一次(3.4新增参数)

WatchSource必须是引用对象;因此它的写法有两种;

  • 如果是响应式的引用对象,如ref,reactive; 直接写变量名即可;
  • 如果是基础数据,需要使用getter函数;

getter函数的使用除了上面的情况还有一个就是获取引用对象新旧值的时候会用到;

2. 侦听单个数据源及停止侦听

<script setup>
  import { watch, ref, reactive } from 'vue'
  // 侦听一个 getter
  const person = reactive({name: '小松菜奈'})
  watch(
    () => person.name,
    (value, oldValue) => {
      console.log(value, oldValue)
    }, {immediate:true}
  )
  person.name = '有村架纯'

  // 直接侦听ref  停止侦听
  const ageRef = ref(16)
  const stopAgeWatcher = watch(ageRef, (value, oldValue) => {
    console.log(value, oldValue)
    if (value > 18) {
      stopAgeWatcher() // 当ageRef大于18,停止侦听
    }
  })

  const changeAge = () => {
    ageRef.value += 1
  }
</script>

现象

配置了immediate:truewatch,在初始化时触发了一次watch的回调。我们连续点击增加年龄,当年龄 的当前值大于18时,watch停止了侦听。

结论

侦听器数据源可以是返回值的 getter 函数,也可以直接是 refwatch函数是有返回值的,返回值是停止器,然后通 过执行停止器() 函数来停止侦听。

3. 监听多个数据源

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

  const name = ref('小松菜奈')
  const age = ref(25)

  watch([name, age], ([name, age], [prevName, prevAge]) => {
    console.log('newName', name, 'oldName', prevName)
    console.log('newAge', age, 'oldAge', prevAge)
  })

  // 如果你在同一个函数里同时改变这些被侦听的来源,侦听器只会执行一次
  const change1 = () => {
    name.value = '有村架纯'
    age.value += 2
  }

  // 用 nextTick 等待侦听器在下一步改变之前运行,侦听器执行了两次
  const change2 = async () => {
    name.value = '新垣结衣'
    await nextTick()
    age.value += 2
  }
</script>

现象

以上,当我们在同一个函数里同时改变nameage两个侦听源,watch的回调函数只触发了一次;当我们 在nameage的改变之间增加了一个nextTickwatch回调函数触发了两次。

结论

我们可以通过watch侦听多个数据源的变化。如果在同一个函数里同时改变这些被侦听的来源,侦听器只会 执行一次。若要使侦听器执行多次,我们可以利用 nextTick ,等待侦听器在下一步改变之前运行。

4. 侦听引用对象

<template>
  <div>
    <div>ref定义数组:{{arrayRef}}</div>
    <div>reactive定义数组:{{arrayReactive}}</div>
  </div>
  <div>
    <button @click="changeArrayRef">改变ref定义数组第一项</button>
    <button @click="changeArrayReactive">改变reactive定义数组第一项</button>
  </div>
</template>

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

  const arrayRef = ref([1, 2, 3, 4])
  const arrayReactive = reactive([1, 2, 3, 4])

  // ref not deep, 不能深度侦听
  const arrayRefWatch = watch(arrayRef, (newValue, oldValue) => {
    console.log('newArrayRefWatch', newValue, 'oldArrayRefWatch', oldValue)
  })

  // ref deep, 深度侦听,新旧值一样
  const arrayRefDeepWatch = watch(arrayRef, (newValue, oldValue) => {
    console.log('newArrayRefDeepWatch', newValue, 'oldArrayRefDeepWatch', oldValue)
  }, {deep: true})

  // ref deep, getter形式 , 新旧值不一样
  const arrayRefDeepGetterWatch = watch(() => [...arrayRef.value], (newValue, oldValue) => {
    console.log('newArrayRefDeepGetterWatch', newValue, 'oldArrayRefDeepGetterWatch', oldValue)
  })

  // reactive,默认深度监听,可以不设置deep:true, 新旧值一样
  const arrayReactiveWatch = watch(arrayReactive, (newValue, oldValue) => {
    console.log('newArrayReactiveWatch', newValue, 'oldArrayReactiveWatch', oldValue)
  })

  // reactive,getter形式 , 新旧值不一样
  const arrayReactiveGetterWatch = watch(() => [...arrayReactive], (newValue, oldValue) => {
    console.log('newArrayReactiveFuncWatch', newValue, 'oldArrayReactiveFuncWatch', oldValue)
  })

  const changeArrayRef = () => {
    arrayRef.value[0] = 3
  }
  const changeArrayReactive = () => {
    arrayReactive[0] = 6
  }
</script>

现象

  • 当将引用对象采用ref形式定义时,如果不加上deep:true watch侦听不到值的变化的;而加 deep:truewatch可以侦听到数据的变化,但是当前值和先前值一样,即不能获取旧值。
  • 当将引用对象采用 reactive形式定义时,不作任何处理,watch可以侦听到数据的变化,但是当前值和旧值一样。
  • 两种定义下,把watch的数据源写成getter函数的形式并进行深拷贝返回,可以在watch回调中同时获得当前值和旧值。
    const objReactive = reactive({user: {name: 'aa', age: '18'}, brand: 'Channel'});
    
    /** 对象深度监听的最佳实践- reactive且源采用函数式返回,返回深拷贝后的数据 */
    watch(() => _.cloneDeep(objReactive), (newVal, oldVal) => {
      console.log('newVal', newVal);
      console.log('oldVal', oldVal);
    })

结论: 当我们使用watch侦听引用对象时

  • 若使用ref定义的引用对象:
    • 只要获取当前值,watch第一个参数直接写成数据源,另外需要加上deep:true选项
    • 若要获取当前值和旧值,需要把数据源写成getter函数的形式,并且需对数据源进行深拷贝
  • 若使用 reactive定义的引用对象:
    • 只要获取当前值,watch第一个参数直接写成数据源,可以不加deep:true选项
    • 若要获取当前值和旧值,需要把数据源写成getter函数的形式,并且需对数据源进行深拷贝

5. watchEffect

watchEffect(callback, options): 只有两个参数,第一个是回调函数,第二个是配置项,配置项参数与watch一样;

watchEffect会立即执行,不像watch是惰性的;当然也可以通过watch配置项加{immediate: true}实现;

const number = reactive({ count: 0 });
const countAdd = () => {
  number.count++;
};
watchEffect(()=>{
  console.log("新的值:", number.count);
})

TIP

watchEffect 仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个 await 正常工作前访问到的属性才会被追踪。

以便dom更新之后运行watchEffect, 有个简单写法

watchPostEffect(() => {
  /* 在 Vue 更新后执行 */
})

6. watch, watchEffect的区别

  1. watchwatchEffect 都能监听响应式数据的变化,不同的是它们监听数据变化的方式不同。
  2. watch 会明确监听某一个响应数据,而 watchEffect则是隐式的监听回调函数中响应数据。
  3. watch 在响应数据初始化时是不会执行回调函数的,watchEffect 在响应数据初始化时就会立即执行回调函数。

7. FAQ

通常来说,我们的一个组件被销毁或者卸载后,监听器也会跟着被停止,并不需要我们手动去关闭监听器。但是总是有一些特殊情况,即使组件卸载了,但是监听器依然存在,这个时候其实式需要我们手动关闭它的,否则容易造成内存泄漏。

比如下面这中写法,我们就需要手动停止监听器:

<script setup>
import { watchEffect } from 'vue'
// 它会自动停止
watchEffect(() => {})
// ...这个则不会!
setTimeout(() => {
  watchEffect(() => {})
}, 100)
</script>

上段代码中我们采用异步的方式创建了一个监听器,这个时候监听器没有与当前组件绑定,所以即使组件销毁了,监听器依然存在。

关闭方法很简单,代码如下:

const unwatch = watchEffect(() => {})
// ...当该侦听器不再需要时
unwatch()

感谢您抽出宝贵的时间观看本文;本文是 Vue3 核心 API 系列的第 1 篇,后续会持续更新 computed、ref/reactive、生命周期等实战内容,同时正在整理「Vue3 完整项目实战小册」(包含从 0 到 1 开发小程序 / 管理系统的全流程),欢迎关注~

Vue 组件设计优化:别把控制显隐的 v-if 藏在子组件里

2026年3月18日 09:38

前言

在大型 Vue 项目的业务迭代中,我们经常会遇到这样的场景:一个详情页底部挂载着各种功能按钮(如:拉黑、不感兴趣、举报、反馈)。这些按钮的显示与否,往往取决于详情接口返回的各种权限位。

很多开发者的第一反应是: “把逻辑封装进子组件,传一个权限标识进去不就行了?”

看起来很优雅,但实际上,这种做法隐藏着巨大的性能浪费和维护风险。今天,我们通过一次真实的业务重构,聊聊 Vue 组件设计中的 “逻辑层级对齐”“单事实来源(SSOT)” 原则。

一、 滥用 Prop 传值的“多此一举”

假设我们有一个“拉黑权限” blackFlag,有些同学的组件化是这样的:

父组件:

<!-- 父组件拿到了权限,却把结论传给子组件,让子组件自己去藏起来 -->
<Child :black-flag="blackFlag" />

子组件:

<template>
  <div v-if="blackFlag">
    <!-- 业务代码 -->
  </div>
</template>
<script>
export default {
  props: { blackFlag: Boolean }
}
</script>

这种写法的三个“槽点”:

  1. 冗余的状态同步:关于“是否展示”这个事实,被父子组件同时观察,如果子组件内部不小心修改了对这个 Prop 的理解,或者在 computed 里又包了一层逻辑,就会出现“父组件想关,子组件关不掉”的情况。
  2. 多余的生命周期:哪怕 blackFlag 是 false,子组件也会被实例化,执行 data 初始化和生命周期钩子,白白占用内存。
  3. 脆弱的健壮性:如果子组件在 created 里注册了全局滚动监听或定时器,即便 UI 隐藏了,后台逻辑依然会“僵尸式”运行,极易引发内存泄漏或不可控的 Bug。

二、 健壮性原则:逻辑在哪里,控制就在哪里

重构的核心思路:让父组件决定子组件的“生死”,而不是“显隐”。

既然权限逻辑(接口请求过程)是在父组件完成的,那么父组件就应该掌握子组件的挂载权。

重构后的父组件:

<!-- 权限不满足,子组件压根不会生效 -->
<Child v-if="blackFlag" />

重构后的子组件:

<template>
  <div>
    <!-- 只需要关注业务:既然我被创建了,我就一定要展示 -->
  </div>
</template>
<script>
export default {
  // 删掉冗余的 blackFlag Prop
}
</script>

这样做带来的“绝对健壮性”:

由于 v-if 的 惰性(Lazy) 特质,当条件为假时,Vue 会确保子组件内部的任何 JS 逻辑、观察者(Watchers)、事件监听、甚至子组件的子组件都不会运行。这从物理层面上切断了任何潜在副作用产生的可能。

三、 进阶思考:代码加载了吗?

有些同学会问: “如果 v-if 把组件隐藏了,子组件的 JS 代码还会被浏览器加载吗?”

这需要分情况讨论:

情况 A:静态引入 (Static Import)

import Child from './Child.vue'; 
  • 加载情况:代码会被打包进主 Chunk。浏览器打开页面时代码已下载,但在内存中处于“静默”状态,不执行、不实例化

情况 B:动态引入 (Lazy Loading)

components: { 
  Child: () => import('./Child.vue') 
}
  • 加载情况:高能预警!如果 v-if 条件一开始为 false,浏览器连这个 JS 文件都不会去下载。只有条件变为 true 的瞬间,才会触发网络请求拉取 Chunk。

结论:结合 v-if 和动态引入,你可以实现极致的性能优化——不仅节省内存和执行开销,连用户的网络带宽都省了。

四、 颗粒度权衡:如何选择组件策略?

并不是所有 v-if 都要往上提。我们可以根据 “逻辑归属权” 来快速站队:

  1. 外部决策型逻辑:如“当前用户是否有权限”、“是否从特定入口进入”。
    • 策略:逻辑留父组件,v-if 挂在标签上。
  1. 自我决策型逻辑:如“该组件需要实时轮询一个接口来决定自己变不变红”。
    • 策略:逻辑搬进子组件,获取逻辑与 v-if 共同留在子组件根节点。

五、结语

一个好的工程师,不应该只关注组件长什么样,更应该关注 “组件的生命周期从哪里开始,到哪里结束”

遵循 “谁管理状态,谁控制显隐” 的原则,能让你的代码告别脆弱的 Prop 同步,走向真正的健壮与高性能。

最新版vue3+TypeScript开发入门到实战教程之watch详解

作者 angerdream
2026年3月17日 16:43

1、watch概述

watch本意是监视、观察。它的功能就是监视数据的变化。数据一旦变化,就会产生两种数据:新数据、旧数据。 如业务场景中,当订单量大多某个数时,就发放优惠卷。watch非常重要,掌握好响应式数据、computed、watch,vue写功能不会有太大问题。 在vue官网明确表达,watch可以监视以下四种数据:

  • ref定义的数据
  • reactive定义的数据
  • 函数返回一个值(getter函数
  • 包含上诉三种值的数组

2、监视ref定义的基本类型数据

  • 创建组件Fish
  • 引入ref、watch
  • 创建响应式数据name、price
  • watch函数监视price变化
  • 当price超过10,watch停止监视price变化
<template>
  <h2>鱼类:{{ name }}</h2>
  <h2>价格:{{ price }}</h2>
  <button @click="addPrice()">增加价格</button>
</template>
<script setup>
import { ref, watch } from 'vue'
let name = ref('鲫鱼');
let price = ref(5);
function addPrice() {
  price.value += 1;
}
let stopWatchPrice = watch(price, (newValue, oldValue) => {
  console.log(newValue, oldValue);
  if (newValue > 10) {
    console.log(stopWatchPrice);
    stopWatchPrice.stop();
  }
})
</script>

运行效果事例: 在这里插入图片描述 注意watch函数,监视的是price,而不是price.value。当点击按钮,price超过10,虽然数据在增加,但不再监视price。watch函数返回对象,有stop函数,调用此函数,即可解除监视。控制台打印,其结构如下:

() => {
    effect2.stop();
    if (scope && scope.active) {
      remove(scope.effects, effect2);
    }
  }

3、监视ref定义的对象类型数据

监视对象类型的数据,与基础类型的数据不同。当对象中的数据变化时,是无法监视到,但当整个数据改变时,是可以监视的。特点如下:

  • 创建组件Fish,引入ref、watch
  • 创建响应式对象fish,let fish = ref({ name: '鲫鱼', price: 5 });
  • 当改变fish.name值时,无法监视fish的变化
  • 当改变fish.price值时,无法监视fish的变化
  • 当改变整条鱼时,能够监视fish变化
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{ fish.price }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changePrice()">修改鱼价</button>
  <button @click="changeFish()">更换真个鱼</button>
</template>
<script setup>
import { ref, watch } from 'vue'
let fish = ref({ name: '鲫鱼', price: 5 });

function changeName() {
  fish.value.name += '~';
}
function changePrice() {
  fish.value.price += 1;
}
function changeFish() {
  fish.value = { name: '鲤鱼', price: 10 };
}
watch(fish, (newValue, oldValue) => {
  console.log(newValue, oldValue);
})

运行效果如下: 在这里插入图片描述 当修改响应式对象成员变量时,不会引起fish watch函数运行。原因在于watch监视的不是fish.name而是fish。那么如何才能监视fish.namefish.price数据变化呢? watch函数,它有三个参数

  • 一是监视对象
  • 二是监视回调函数,
  • 三是配置对象参数,如deep等等 只有在配置对象开启deep即可。
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{ fish.price }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changePrice()">修改鱼价</button>
  <button @click="changeFish()">更换真个鱼</button>
</template>
<script setup>
import { ref, watch } from 'vue'
let fish = ref({ name: '鲫鱼', price: 5 });

function changeName() {
  fish.value.name += '~';
}
function changePrice() {
  fish.value.price += 1;
}
function changeFish() {
  fish.value = { name: '鲤鱼', price: 10 };
}
watch(fish, (newValue, oldValue) => {
  console.log(newValue, oldValue);
}, { deep: true })
</script>

运行效果,仔细观看控制台打印的新数据、旧数据。

  • fish.name改变时,新旧数据一样
  • fish.price改变时,新旧数据一样
  • 当fish整个改变时,新旧数据不一样 效果如图: 在这里插入图片描述 注意fish.namefish.price,新旧数据是一样的。因为watch是从对象地址取到的数据。

4、watch监视函数返回一个值(getter函数)

它的功能是wath监视响应式对象中一个属性,如监视fish.name,是不允许直接监视,需要写成一个函数的形式。

  • 创建组件Fish,引入reactive, watch
  • 创建响应式对象fish,鱼的名字,鱼的体型:长度、重量
  • 分别监听鱼的名字与体型
  • 点击按钮修改鱼类,鱼的长度、鱼的重量、鱼的体型
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>鱼长度:{{ fish.body.long }}</h2>
  <h2>鱼重量:{{ fish.body.weight }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changeFishLong()">修改鱼的长度</button>
  <button @click="changeFishWeight()">修改鱼的重量</button>
  <button @click="changeFishbody()">修改鱼的体型</button>
</template>
<script setup>
import { reactive, watch } from 'vue'
let fish = reactive({ name: '鲫鱼', body: { long: 1, weight: 24 } });

function changeName() {
  fish.name += '~';
}
function changeFishLong() {
  fish.body.long += 1;
}
function changeFishWeight() {
  fish.body.weight += 1;
}
function changeFishbody() {
  fish.body = { long: 100, weight: 300 };
}
watch(() => { return fish.name }, (newValue, oldValue) => {
  console.log('监听fish.name', newValue, oldValue);
})
watch(() => { return fish.body }, (newValue, oldValue) => {
  console.log('监听fish.body', newValue, oldValue);
})
</script>

监听响应式对象中的参数,需要写成一个箭头函数,并返回监听参数即可。具体操作,看下图: 在这里插入图片描述 当点击按钮,发现只有修改鱼类、修改鱼的体型,才能监听到变化。这是因为watch监听的地址。若想要能够监听到鱼的长度、鱼的重量,需要再watch加入deep参数即可。

watch(() => { return fish.body }, (newValue, oldValue) => {
  console.log('监听fish.body', newValue, oldValue);
}, { deep: true })

在这里插入图片描述 注意点击按钮修改鱼的长度、修改鱼的重量,新旧数据是一致的。

5、watch监视含有响应式对象数组的数据

watch监视的对象是一个数组,数组内可以是ref定义基本类型数据,也可是对象,可以是函数。

watch([() => { return fish.name },() => { return fish.body }], (newValue, oldValue) => {
  console.log('监听素组', newValue, oldValue);
}, { deep: true })

由于使用配置参数deep,操作效果如下: 在这里插入图片描述

具体代码

<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>鱼长度:{{ fish.body.long }}</h2>
  <h2>鱼重量:{{ fish.body.weight }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changeFishLong()">修改鱼的长度</button>
  <button @click="changeFishWeight()">修改鱼的重量</button>
  <button @click="changeFishbody()">修改鱼的体型</button>
</template>
<script setup>
import { reactive, watch } from 'vue'
let fish = reactive({ name: '鲫鱼', body: { long: 1, weight: 24 } });

function changeName() {
  fish.name += '~';
}
function changeFishLong() {
  fish.body.long += 1;
}
function changeFishWeight() {
  fish.body.weight += 1;
}
function changeFishbody() {
  fish.body = { long: 100, weight: 300 };
}
watch([() => { return fish.name },() => { return fish.body }], (newValue, oldValue) => {
  console.log('监听素组', newValue, oldValue);
}, { deep: true })
</script>

6、总结 watch可以监视四种数据,再加上配置函数,内容多且难记。在项目中多练习几次就能熟记。

  • ref定义的数据
  • reactive定义的数据
  • 函数返回一个值(getter函数
  • 包含上诉三种值的数组 在现实开发中,第一种和第三种情况最常用。尤其第三种情况,加函数,加配置参数deep。属于重中之重。
watch(() => { return fish.body }, (newValue, oldValue) => {
  console.log('监听fish.body', newValue, oldValue);
}, { deep: true })

Vue2 vs Vue3 全面对比(含代码示例+迁移指南)

作者 远山枫谷
2026年3月17日 14:16

Vue2 vs Vue3 全面对比(含代码示例+迁移指南)

作为前端开发者,Vue框架的升级迭代一直是我们关注的重点。从2019年Vue3发布beta版,到如今Vue3成为新项目的首选,两者之间的差异不仅体现在底层实现,更贯穿了开发流程的方方面面。今天我们就来全面拆解Vue2与Vue3的核心区别,结合代码示例帮你快速吃透差异,轻松应对项目迁移与开发选型。

本文将从「核心架构」「响应式原理」「语法特性」「性能优化」「生态工具」「迁移实践」6大维度展开,覆盖日常开发中90%以上会遇到的差异点,新手可快速入门,老开发者可查漏补缺。

一、核心架构:Options API vs Composition API

这是Vue2与Vue3最本质的区别,核心在于「代码组织方式」的不同——Vue2采用Options API(选项式API),Vue3引入Composition API(组合式API),同时兼容Options API,兼顾老项目迁移与新项目开发。

1. Vue2:Options API

Options API通过「选项」划分代码逻辑,将组件的逻辑拆分为data、methods、computed、watch、生命周期钩子等选项,结构固定,入门门槛低,但在复杂组件中会出现「逻辑分散」的问题。

比如一个包含数据请求、表单校验、状态管理的复杂组件,相关逻辑会分散在data、methods、mounted等不同选项中,后期维护时需要在多个选项间来回切换,可读性和可复用性较差。

<script>
// Vue2 Options API 示例
export default {
  // 数据
  data() {
    return {
      userInfo: null,
      loading: false,
      error: ''
    }
  },
  // 计算属性
  computed: {
    isUserLoaded() {
      return !!this.userInfo
    }
  },
  // 生命周期钩子
  mounted() {
    this.getUserInfo()
  },
  // 方法
  methods: {
    async getUserInfo() {
      this.loading = true
      try {
        const res = await fetch('/api/user')
        this.userInfo = await res.json()
      } catch (err) {
        this.error = err.message
      } finally {
        this.loading = false
      }
    }
  }
}
</script>

2. Vue3:Composition API

Composition API以「功能」为核心,通过组合函数(Composable)将相关逻辑聚合在一起,打破了Options API的选项限制,让代码组织更灵活,尤其适合复杂组件和逻辑复用。

Vue3中可以通过setup函数(或

<script setup>
// Vue3 Composition API 示例(<script setup>语法糖,推荐)
import { ref, computed, onMounted } from 'vue'

// 1. 定义响应式数据(替代data)
const userInfo = ref(null)
const loading = ref(false)
const error = ref('')

// 2. 计算属性(替代computed)
const isUserLoaded = computed(() => !!userInfo.value)

// 3. 逻辑抽离(可单独抽离为组合函数,供其他组件复用)
const getUserInfo = async () => {
  loading.value = true
  try {
    const res = await fetch('/api/user')
    userInfo.value = await res.json()
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

// 4. 生命周期钩子(替代mounted)
onMounted(() => {
  getUserInfo()
})
</script>

核心差异总结

维度 Vue2(Options API) Vue3(Composition API)
代码组织 按选项划分(data、methods等) 按功能聚合(组合函数)
逻辑复用 依赖mixins,易出现命名冲突 组合函数,无命名冲突,复用性更强
复杂组件 逻辑分散,维护困难 逻辑聚合,可读性高
入门难度 低,结构固定 稍高,需理解组合逻辑

二、响应式原理:Object.defineProperty vs Proxy

响应式是Vue的核心特性,Vue2和Vue3的响应式实现方式完全不同,这也是Vue3性能提升的关键原因之一。两者的核心差异在于「数据劫持的方式」,Vue2基于Object.defineProperty,Vue3基于Proxy+Reflect,后者从根本上解决了前者的诸多局限性。

1. Vue2:Object.defineProperty

Vue2通过Object.defineProperty劫持对象的getter和setter方法,实现对数据变化的监听。但这种方式存在3个明显的局限性,也是开发中常遇到的痛点:

  • 无法监听对象新增/删除的属性(需通过Vue.set、Vue.delete手动触发响应);
  • 无法监听数组的索引变化和长度变化(需重写数组方法,如push、splice等);
  • 只能劫持对象的属性,无法直接劫持整个对象,初始化时需递归遍历对象所有属性,性能开销较大。
// Vue2 响应式核心实现(简化版)
function defineReactive(obj, key, value) {
  // 递归监听嵌套对象
  if (typeof value === 'object' && value !== null) {
    observe(value)
  }
  Object.defineProperty(obj, key, {
    get() {
      // 依赖收集
      track(obj, key)
      return value
    },
    set(newValue) {
      if (newValue === value) return
      value = newValue
      // 触发更新
      trigger(obj, key)
    }
  })
}

// 监听对象
function observe(obj) {
  if (typeof obj !== 'object' || obj === null) return
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
}

// 痛点示例:新增属性无法监听
const obj = { name: 'Vue2' }
observe(obj)
obj.age = 3 // 新增属性,无法触发响应式更新
Vue.set(obj, 'age', 3) // 需手动调用Vue.set

2. Vue3:Proxy + Reflect

Vue3放弃了Object.defineProperty,转而使用ES6新增的Proxy(代理)和Reflect(反射),从根本上解决了Vue2的局限性。Proxy可以直接代理整个对象,而非单个属性,同时支持监听对象的所有操作(新增、删除、数组变化等),且无需递归遍历,性能更优。

Reflect则与Proxy相辅相成,提供了一套用于操作对象的方法集合,能更优雅地处理代理过程中的对象操作,比如自动传递this上下文、统一返回操作结果等,让代码更健壮。

// Vue3 响应式核心实现(简化版)
function reactive(obj) {
  return new Proxy(obj, {
    // 读取属性时触发
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver)
      // 依赖收集
      track(target, key)
      // 懒代理:嵌套对象访问时才创建代理,减少初始化性能开销
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      return result
    },
    // 设置属性时触发
    set(target, key, value, receiver) {
      const oldValue = Reflect.get(target, key, receiver)
      const result = Reflect.set(target, key, value, receiver)
      if (oldValue !== value) {
        // 触发更新
        trigger(target, key)
      }
      return result
    },
    // 删除属性时触发
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      // 触发更新
      trigger(target, key)
      return result
    }
  })
}

// 优势示例:自动监听新增/删除属性、数组变化
const obj = reactive({ name: 'Vue3' })
obj.age = 1 // 新增属性,自动触发响应
delete obj.name // 删除属性,自动触发响应

const arr = reactive([1, 2, 3])
arr.push(4) // 数组操作,自动触发响应
arr[0] = 0 // 数组索引修改,自动触发响应

响应式差异总结

特性 Vue2(Object.defineProperty) Vue3(Proxy+Reflect)
对象新增属性 不支持,需手动调用Vue.set 支持,自动监听
对象删除属性 不支持,需手动调用Vue.delete 支持,自动监听
数组索引/长度变化 不支持,需使用重写方法 支持,自动监听
嵌套对象监听 初始化时递归遍历,性能差 懒代理,访问时才监听,性能优
数据类型支持 仅支持对象/数组 支持对象、数组、Map、Set等

三、生命周期钩子:命名调整与使用方式变化

Vue3的生命周期钩子基本沿用了Vue2的逻辑,但进行了部分命名调整,同时适配Composition API的使用方式,新增了setup钩子(Composition API的入口),废弃了部分钩子。

1. 生命周期钩子对应关系

Vue2(Options API) Vue3(Options API) Vue3(Composition API,需导入)
beforeCreate beforeCreate(兼容) setup(替代,执行时机更早)
created created(兼容) setup(替代)
beforeMount beforeMount(兼容) onBeforeMount
mounted mounted(兼容) onMounted
beforeUpdate beforeUpdate(兼容) onBeforeUpdate
updated updated(兼容) onUpdated
beforeDestroy beforeUnmount(重命名) onBeforeUnmount
destroyed unmounted(重命名) onUnmounted
activated activated(兼容) onActivated
deactivated deactivated(兼容) onDeactivated

2. 核心变化说明

  • setup钩子:替代beforeCreate和created,是Composition API的入口,执行时机在beforeCreate之前,此时组件实例尚未创建,无法访问this(Vue3中Composition API不依赖this);
  • 钩子重命名:beforeDestroy → beforeUnmount,destroyed → unmounted,更贴合语义(组件卸载而非销毁);
  • Composition API中使用钩子:需从vue中导入对应的钩子函数,然后在setup中调用,支持多次调用(按调用顺序执行)。
<script setup>
// Vue3 Composition API 生命周期使用示例
import { onMounted, onBeforeUnmount, onUpdated } from 'vue'

// 组件挂载后执行
onMounted(() => {
  console.log('组件挂载完成')
})

// 组件更新前执行
onUpdated(() => {
  console.log('组件更新完成')
})

// 组件卸载前执行
onBeforeUnmount(() => {
  console.log('组件即将卸载')
})
</script>

四、模板语法:增强与简化

Vue3的模板语法基本兼容Vue2,但新增了部分实用特性,同时简化了部分语法,提升开发效率,减少冗余代码。

1. 新增特性

(1)多根节点(Fragments)

Vue2中组件模板只能有一个根节点(需用div等标签包裹),否则会报错;Vue3支持多根节点,无需额外包裹,减少DOM层级冗余,优化渲染性能。

// Vue2(错误示例:多根节点)
<template>
  <h1>Vue2</h1>
  <p>只能有一个根节点</p>
</template>

// Vue2(正确示例:需包裹div)
<template>
  <div>
    <h1>Vue2</h1>
    <p>只能有一个根节点</p>
  </div>
</template>

// Vue3(正确示例:多根节点)
<template>
  <h1>Vue3</h1>
  <p>支持多根节点,无需包裹</p>
</template>
(2)v-model 语法简化与增强

Vue2中v-model本质是:value + @input的语法糖,且只能绑定一个值;Vue3简化了v-model的使用,同时支持多值绑定、自定义修饰符,统一了组件通信的语法。

// Vue2 v-model 使用(单一绑定)
<template>
  <input v-model="value">
  // 等价于
  <input :value="value" @input="value = $event.target.value">
</template>

// Vue3 v-model 使用(多值绑定+自定义修饰符)
<template>
  // 1. 单一绑定(简化,无需手动处理$event)
  <input v-model="value">
  
  // 2. 多值绑定(绑定多个属性)
  <ChildComponent 
    v-model:name="name" 
    v-model:age="age"
  />
  
  // 3. 自定义修饰符(如v-model.trim)
  <input v-model.trim="value">
</template>
(3)动态指令参数

Vue3支持动态绑定指令参数,让指令使用更灵活,可根据数据动态切换指令的目标(如动态绑定v-bind、v-on的参数)。

<script setup>
import { ref } from 'vue'
const propName = ref('title')
const eventName = ref('click')
</script>

<template>
  // 动态绑定v-bind参数
  <div v-bind:[propName]="'Vue3动态指令'"></div>
  
  // 动态绑定v-on参数
  <button v-on:[eventName]="handleClick">点击</button>
</template>
(4)Teleport(瞬移组件)

Vue3新增Teleport组件,可将组件内容“瞬移”到页面的任意DOM节点中(如body),解决了弹窗、模态框等组件的层级问题,无需担心父组件的样式隔离影响。

<template>
  <teleport to="body">
    <div class="modal">
      这是一个弹窗,将被渲染到body中
    </div>
  </teleport>
</template>
(5)Suspense(异步组件占位)

Vue3新增Suspense组件,用于异步组件的加载占位,可在异步组件加载完成前显示loading状态,加载失败时显示错误提示,简化异步组件的处理逻辑。

<template>
  <suspense>
    <template #default>
      // 异步组件(需动态导入)
      <AsyncComponent />
    </template>
    <template #fallback>
      // 加载中占位
      <div>加载中...</div>
    </template>
  </suspense>
</template>

<script setup>
// 动态导入异步组件
const AsyncComponent = defineAsyncComponent(() => import('./AsyncComponent.vue'))
</script>

2. 废弃特性

  • v-on.native修饰符:Vue3中组件的原生事件无需使用.native修饰符,可直接绑定,若需区分组件自定义事件和原生事件,可通过emits选项声明自定义事件;
  • 过滤器(filter):Vue3废弃了过滤器,推荐使用计算属性或全局方法替代(过滤器的功能可完全通过计算属性实现,且更灵活);
  • v-bind.sync修饰符:Vue3中可用v-model:xxx替代,统一了双向绑定的语法。

五、性能优化:全方位提升

Vue3在性能上做了大量优化,相比Vue2,渲染速度提升30%+,打包体积减少约50%,主要优化点集中在编译优化、响应式优化、体积优化三个方面。

1. 编译优化

Vue3重写了模板编译逻辑,引入「静态标记(Patch Flag)」和「Block Tree」机制,实现按需更新,减少不必要的DOM操作:

  • 静态标记:编译模板时,对静态节点(不随数据变化的节点)添加标记,更新时跳过静态节点,只处理动态节点;
  • Block Tree:将模板拆分为多个Block(代码块),每个Block对应一个动态节点集合,更新时只遍历对应Block的动态节点,而非整个虚拟DOM树。

2. 响应式优化

如前文所述,Vue3使用Proxy+Reflect替代Object.defineProperty,实现以下优化:

  • 懒代理:嵌套对象只有在访问时才会创建代理,减少初始化时的性能开销;
  • 精准监听:只监听变化的属性,无需遍历整个对象,更新更高效;
  • 支持更多数据类型:Map、Set等集合类型也能实现响应式,满足更多开发场景。

3. 体积优化

Vue3支持Tree-shaking(树摇),只打包项目中用到的API,未使用的功能(如过滤器、v-on.native等)不会被打包,核心包体积从Vue2的约20KB缩减到最小10KB左右,大幅提升项目加载速度。

六、TypeScript支持:从兼容到原生

Vue2对TypeScript的支持较差,需要通过vue-class-component、vue-property-decorator等第三方库实现TS支持,且类型推导不精准,开发体验不佳;Vue3则是基于TypeScript原生开发的,天生支持TS,类型推导更精准,开发体验大幅提升。

核心差异

  • Vue2:需额外配置第三方库,类型定义不完整,组件内this指向不明确,类型推导困难;
  • Vue3:原生支持TS,Composition API的函数式写法更易推导类型,defineProps、defineEmits等宏支持泛型定义,模板中表达式的类型错误可在编译时被捕获,且核心代码的类型定义更完善。
<script setup lang="ts">
// Vue3 + TS 示例
import { ref, computed } from 'vue'

// 1. 基础类型响应式数据
const count = ref<number>(0)

// 2. 复杂类型响应式数据
interface User {
  name: string
  age: number
}
const user = ref<User | null>(null)

// 3. 计算属性类型推导
const doubleCount = computed(() => count.value * 2) // 自动推导为number类型

// 4. 组件props类型定义(defineProps宏)
const props = defineProps<{
  title: string
  count?: number // 可选属性
}>()

// 5. 组件事件类型定义(defineEmits宏)
const emit = defineEmits<{
  (e: 'change', value: number): void
}>()
</script>

七、生态与工具链:全面升级

Vue3的生态系统也同步升级,核心工具和第三方库均已适配Vue3,同时新增了更高效的开发工具,提升开发体验。

1. 核心工具

工具 Vue2 Vue3
构建工具 Vue CLI(基于Webpack) Vite(推荐,基于ESBuild,冷启动更快)、Vue CLI(兼容)
路由 vue-router@3.x vue-router@4.x(适配Composition API,支持TS)
状态管理 Vuex@3.x Pinia(推荐,更轻量、支持TS、API更简洁)、Vuex@4.x(兼容)
UI组件库 Element UI、Vuetify@2.x Element Plus、Vuetify@3.x、Ant Design Vue@3.x

2. 全局API变化

Vue3对全局API进行了重构,从“全局挂载”改为“实例化挂载”,支持多实例隔离,避免全局污染,同时简化了部分API的使用。

// Vue2 全局API使用
import Vue from 'vue'
import App from './App.vue'

// 全局注册组件
Vue.component('MyComponent', MyComponent)

// 全局注册指令
Vue.directive('my-directive', {})

// 全局配置
Vue.config.productionTip = false

// 创建实例
new Vue({
  render: h => h(App)
}).$mount('#app')

// Vue3 全局API使用(实例化方式)
import { createApp } from 'vue'
import App from './App.vue'

// 创建app实例
const app = createApp(App)

// 实例注册组件
app.component('MyComponent', MyComponent)

// 实例注册指令
app.directive('my-directive', {})

// 实例配置
app.config.productionTip = false

// 挂载实例
app.mount('#app')

八、实战迁移指南:从Vue2到Vue3

对于现有Vue2项目,无需一次性全部迁移,可采用“渐进式迁移”策略,逐步替换组件和逻辑,降低迁移成本。以下是具体迁移步骤和注意事项:

1. 迁移前准备

  • 检查依赖兼容性:升级核心依赖(Vue、vue-router、Vuex/Pinia),确保第三方组件库和插件支持Vue3(如Element UI替换为Element Plus);
  • 检查语法兼容性:移除Vue2中废弃的特性(过滤器、v-on.native、v-bind.sync等),替换为Vue3的替代方案;
  • 创建迁移分支:建议在Git中创建专门的迁移分支,避免影响主分支的正常开发。

2. 核心依赖升级命令

# 卸载Vue2相关依赖
npm remove vue vue-router vuex

# 安装Vue3相关依赖
npm install vue@3.2.x vue-router@4.x pinia@2.x

# 安装Vue3编译器(若使用Vue CLI)
npm install @vue/compiler-sfc@3.2.x -D

3. 逐步迁移组件

  • 优先迁移简单组件(如公共组件、基础组件),再迁移复杂组件;
  • 将Options API组件逐步改为Composition API(使用
  • 替换响应式数据写法:将data中的数据替换为ref/reactive,methods中的方法改为普通函数,computed/watch替换为对应的Composition API。

4. 常见迁移问题解决

  • this指向问题:Vue3的Composition API中无this,需通过ref/reactive的.value访问响应式数据;
  • 组件通信问题:将emit替换为defineEmitsemit替换为defineEmits,props替换为defineProps,parent/parent/children替换为provide/inject;
  • 事件总线问题:Vue3废弃了on/on/off/$once,可使用mitt等第三方库实现事件总线功能。

九、总结:该选择Vue2还是Vue3?

经过全面对比,Vue3在核心架构、响应式原理、性能、TS支持等方面均优于Vue2,且完全兼容Vue2的Options API,是未来的主流方向。结合实际开发场景,给出以下选型建议:

  • 新项目:优先选择Vue3 + Vite + Pinia + TS,享受更高效的开发体验和更优的性能;
  • 现有Vue2项目:若项目稳定,无需强制迁移;若需要新增复杂功能或优化性能,可采用渐进式迁移策略,逐步升级;
  • 新手学习:直接学习Vue3,Composition API的思想更贴合现代前端开发,且未来就业需求更高。

Vue3的升级不仅是技术的迭代,更是开发理念的升级——从“按选项组织代码”到“按功能组合代码”,让开发更灵活、更高效、更易维护。希望本文能帮助你全面掌握Vue2与Vue3的区别,顺利完成项目迁移和技术升级!

最后,如果你在迁移过程中遇到问题,或者有其他Vue相关的疑问,欢迎在评论区留言交流~

❌
❌