普通视图

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

从“DOM 操作”到“数据驱动”:Vue 如何重塑前端开发思维

作者 Lee川
2026年3月14日 18:22

从“DOM 操作”到“数据驱动”:Vue 如何重塑前端开发思维

导读:在传统的 Web 开发中,我们习惯于像“外科医生”一样精准地操作每一个 DOM 节点;而在 Vue 的世界里,我们更像是“指挥官”,只需关注数据的变化,剩下的交给框架。本文将通过深度剖析一段现代 Vue 3 待办事项(Todo List)代码,对比传统 demo.html 的实现缺陷,带你深入理解 Vue 的核心开发哲学与代码美学。


一、传统开发的困境:被 DOM 绑架的逻辑

假设我们手头有一份传统的 demo.html 文件(基于原生 JavaScript 或 jQuery 实现)。在这类文件中,实现一个待办事项列表通常意味着:

  1. 手动获取元素document.getElementById('input'), querySelectorAll('li')
  2. 繁琐的事件监听addEventListener('click', ...)addEventListener('keydown', ...)
  3. 直接的 DOM 操作:添加任务时 createElementappendChild;完成任务时 classList.toggle;统计数量时遍历 DOM 节点计数。
  4. 状态同步噩梦:数据变了要手动改 DOM,DOM 变了要手动改数据。一旦遗漏,页面显示与数据不一致的 Bug 随之而来。

这种“命令式”编程让开发者陷入了细节的泥潭:代码耦合严重、维护困难、性能隐患大


二、Vue 的革命:代码深度解析

当我们转向你提供的这段 Vue 3 <script setup> 代码时,会发现一种截然不同的优雅。让我们逐行拆解,看看 Vue 是如何通过响应式系统声明式渲染计算属性来解决传统痛点的。

2.1 响应式基石:ref 与数据焦点

import {ref, computed} from 'vue'

// 响应式数据
const title = ref();
const todos = ref([
  { id:1, title:'吃鸡', done:true },
  { id:2, title:'睡觉', done:true }
]);
  • 传统做法:你需要定义一个数组变量,然后每次修改它时,都要记得去更新页面上的列表。
  • Vue 做法:使用 ref() 将普通变量包裹成响应式引用
    • titletodos 不再是普通变量,而是带有“魔法”的数据容器。
    • 核心逻辑:正如代码注释所言,“vue focus 标题数据业务,修改数据,余下的 dom 更新 vue 替我们做了”。你只需要关心 title.value 是什么,todos.value 里有什么,完全不需要知道页面上有几个 <li> 标签。
    • 访问机制:在 <script> 中通过 .value 访问真实数据(如 title.value),而在 <template> 中 Vue 会自动解包,直接使用 {{ title }}

2.2 声明式渲染:模板即逻辑

<h2>{{ title }}</h2>
<input type="text" v-model="title" @keydown.enter="addTodo">

<ul v-if="todos.length">
  <li v-for="todo in todos" :key="todo.id">
      <input type="checkbox" v-model="todo.done">
      <span :class="{done: todo.done}">{{ todo.title }}</span> 
  </li>
</ul>
<div v-else>
  暂无计划
</div>

这段模板代码展示了 Vue 三大指令的精妙配合,彻底摒弃了手动操作 DOM:

A. 双向绑定 v-model
  • 代码v-model="title"v-model="todo.done"
  • 解析:这是 Vue 最强大的特性之一。
    • 在输入框中,它将输入内容与 title 变量绑定。用户打字,title 自动变;代码修改 title,输入框自动变。
    • 在复选框中,它将勾选状态与 todo.done 绑定。
    • 对比传统:传统写法需要监听 input 事件更新变量,监听变量变化更新 input 值,代码量翻倍且容易出错。Vue 一行搞定。
B. 事件修饰符 @keydown.enter
  • 代码@keydown.enter="addTodo"
  • 解析
    • @v-on: 的缩写,用于监听事件。
    • .enter事件修饰符,意为“只在按下回车键时触发”。
    • 优势:无需在 JS 中写 if (event.key === 'Enter') 判断逻辑,语义清晰,代码极简。注释中提到“不用 addEventListener”,正是指这种声明式绑定的便捷性。
C. 条件与列表渲染 v-if / v-for / :key
  • 代码v-if="todos.length"v-for="todo in todos" :key="todo.id"
  • 解析
    • 智能空状态v-ifv-else 实现了“有数据显示列表,无数据显示提示”的逻辑切换,无需手动 display: none
    • 高效循环v-for 根据 todos 数组自动生成 <li>
    • Key 的作用:key="todo.id" 是 Vue 优化渲染的关键。它给每个节点发了“身份证”,当数组顺序变化或删除项时,Vue 能精准复用 DOM 节点,而不是暴力销毁重建,极大提升性能。
D. 动态 Class 绑定 :class
  • 代码:class="{done: todo.done}"
  • 解析
    • :v-bind: 的缩写。
    • 这是一个对象语法:当 todo.donetrue 时,应用 done 类(灰色删除线);为 false 时,不应用。
    • 数据驱动视图:你不需要写 element.classList.add('done'),只需改变数据 todo.done = true,样式自动生效。

2.3 性能与逻辑的升华:computed 计算属性

代码中两处使用了 computed,这是区分新手与高手的关键。

场景一:统计未完成数量
// 依赖于 todos 响应式数据的计算属性
const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length
})
  • 模板调用{{ active }} / {{ todos.length }}
  • 深度分析
    • 缓存机制:注释写道“computed 缓存 性能优化 只有 todos 变化时才会重新计算”。如果用户只是在输入框打字(触发组件重渲染),但未改变 todos 数组,active 不会重新执行 filter,直接返回缓存结果。
    • 对比劣势方案:如果在模板中直接写 {{ todos.filter(...).length }},每次组件更新(哪怕无关)都会重新遍历数组,浪费性能。
    • 逻辑复用:复杂的过滤逻辑被封装在 JS 中,模板保持干净。
场景二:全选/全不选的高级技巧
const allDone = computed({
  get() {
    return todos.value.every(todo => todo.done)
  },
  set(val) {
    todos.value.forEach(todo => todo.done = val)
  }
})
  • 模板调用<input type="checkbox" v-model="allDone">
  • 深度分析:这是 computed读写模式(Getter/Setter)。
    • **Get **(读):当页面渲染时,检查是否所有任务都完成了 (every)。如果是,全选框自动勾选。
    • **Set **(写):当用户点击全选框时,触发 set,将所有任务的 done 状态设为 val
    • 神奇之处:一个 v-model 同时实现了“状态同步”和“批量修改”。传统 JS 需要分别编写“检查所有状态更新全选框”和“监听全选框更新所有状态”两段逻辑,极易出现不同步 Bug。Vue 将其收敛为一个计算属性,逻辑严密且优雅。

2.4 业务逻辑封装:addTodo 函数

const addTodo = () => {
  if(!title.value) return; // 数据校验
  todos.value.push({
    id: Date.now(), // 使用时间戳生成唯一 ID,比 Math.random() 更可靠
    title: title.value,
    done: false
  })
  // 注意:这里没有操作 DOM!
  // 只要 push 进数组,Vue 会自动在页面上添加一个新的 <li>
}
  • 纯粹的数据操作:函数内部没有任何 document 相关代码。
  • ID 策略:使用 Date.now() 生成唯一 ID,配合 :key 确保列表渲染稳定。
  • 自动响应push 操作触发 Vue 的响应式系统,视图自动更新。

三、思维跃迁:从“怎么做”到“是什么”

通过这段代码,我们可以清晰地看到 Vue 带来的思维转变:

维度 传统 DOM 操作 (demo.html) Vue 数据驱动 (当前代码)
关注点 How:怎么找到元素?怎么添加类名?怎么监听事件? What:数据是什么?状态是什么?
状态同步 手动双向同步,易出错 自动双向绑定 (v-model)
列表渲染 手动循环创建/删除节点 声明式循环 (v-for),自动 Diff
复杂逻辑 分散在事件回调中,难以维护 封装在 computed 中,自动缓存
代码量 多且冗余 少而精悍
可维护性 低,牵一发而动全身 高,逻辑与视图分离

核心心法总结

  1. 数据是唯一真理:不要直接操作 DOM。想改变页面?先改变数据。
  2. 声明式优于命令式:告诉 Vue 你想要什么结果(v-if, v-for),而不是告诉它一步步怎么做。
  3. 计算属性是性能利器:涉及复杂推导或频繁使用的数据,务必使用 computed 利用缓存。
  4. 组合式 API 的内聚性<script setup> 让相关逻辑(如 todos, active, addTodo)聚集在一起,代码组织更符合人类思维。

四、结语

这段看似简单的 Todo List 代码,实则是现代前端开发哲学的缩影。它展示了 Vue 如何通过响应式系统将开发者从繁琐的 DOM 操作中解放出来,让我们能专注于业务逻辑本身。

demo.html 的“手动挡”到 Vue 的“自动挡”,不仅仅是语法的升级,更是开发效率与代码质量质的飞跃。当你习惯了“修改数据即修改视图”的思维模式后,你会发现,构建复杂的交互应用变得前所未有的简单、高效且充满乐趣。

这,就是 Vue 赋予我们的超能力。

Flow Render: 像调用异步函数一样渲染 UI 组件

作者 sxq
2026年3月14日 17:18

Flow Render 提供了一种基于 Promise 的 UI 渲染方式,让你可以像调用异步函数一样渲染组件,并等待用户交互结果

它将分散的状态、回调和组件层级重新组织为线性的 async/await 控制流,让复杂的交互逻辑变得直观且易于维护。

const result = await render(Component)

✨ 核心特性

  • Promise 驱动的 UI 渲染:像调用异步函数一样等待组件的结果
  • 支持任意组件 Promise 化:新组件或现有组件都能接入,无需侵入式改造
  • 控制流集中管理:交互逻辑按顺序书写,避免状态分散和回调嵌套
  • 支持上下文完整继承:继承 theme、i18n、store 等应用上下文
  • 实例隔离,用完即销毁:每次渲染都是独立实例,互不干扰,组件状态自动重置
  • 支持全局与局部渲染:既可挂载在应用根节点,也可绑定到局部组件生命周期

📦 Framework Support

Framework Package
React @flow-render/react (也支持 React Native)
Vue @flow-render/vue
Preact @flow-render/preact
Svelte @flow-render/svelte
Solid @flow-render/solid

🚀 快速开始(React)

第一步:安装

npm i @flow-render/react

第二部:挂载容器

在应用根节点放一个 <Viewport/>,所有动态渲染的组件都会出现在这里。

import { Viewport } from '@flow-render/react'

function App () {
  return (
    <>
      <YourApp/>
      <Viewport/> {/* 动态组件都渲染在这里 */}
    </>
  )
}

第三步:定义组件

Flow Render 支持两种编写组件的模式,你可以根据场景自由选择。

执行器模式(推荐)

组件内部直接声明并使用 resolve / reject 回调,类似 new Promise((resolve, reject)=>...) 的执行器风格。

import { type PromiseResolvers } from '@flow-render/react'

interface Props extends PromiseResolvers<boolean> {
  title: string
}

function ConfirmDialog ({ title, resolve, reject }: Props) {
  return (
    <dialog open>
      <div>{title}</div>
      <div>
        <button onClick={() => resolve(true)}>是</button>
        <button onClick={() => resolve(false)}>否</button>
        <button onClick={() => reject(new Error('取消'))}>取消</button>
      </div>
    </dialog>
  )
}

渲染时自动注入回调:

import { render } from '@flow-render/react'

const result = await render(ConfirmDialog, {
  title: '你确定吗?'
})

适配器模式(灵活强大)

适配器模式让你可以将任意组件的 props 与 Promise 动态关联。你只需提供一个函数,该函数接收 resolve 和 reject,并返回组件的 props。这种方式不仅兼容现有组件,还能实现更复杂的逻辑,例如根据外部数据决定 props、条件渲染、动态绑定等。

interface Props {
  title: string
  onYes: () => void
  onNo: () => void
  onCancel: () => void
}

function ConfirmDialog (props: Props) {
  return (
    <dialog open>
      <div>{props.title}</div>
      <div>
        <button onClick={props.onYes}></button>
        <button onClick={props.onNo}></button>
        <button onClick={props.onCancel}>取消</button>
      </div>
    </dialog>
  )
}

适配器模式渲染时,可以通过适配器函数建立 Promise 与组件回调的关联:

import { render } from '@flow-render/react'

const result = await render(ConfirmDialog, (resolve, reject) => {
  return {
    title: '你确定吗?',
    onYes: () => resolve(true),
    onNo: () => resolve(false),
    onCancel: () => reject(),
  }
})

全局渲染器(默认)

默认情况下,render() 渲染出的动态组件生命周期不跟随调用它的组件,而是跟随全局 Viewport

这意味着:

  • 即使触发渲染的组件已卸载,动态组件仍可继续存在
  • 适合全局弹窗、确认框、选择器、异步引导流程等场景

若希望动态组件在当前页面或当前组件卸载时自动销毁,请使用局部渲染器


局部渲染器

使用 useRenderer() 可以创建一个与当前组件生命周期绑定的局部渲染器。

适用场景:

  • 页面级弹窗
  • 需跟随局部区域销毁的交互
  • 希望自定义渲染位置
import { useRenderer } from '@flow-render/react'

function Page () {
  const [render, Viewport] = useRenderer()

  return (
    <div>
      <button onClick={() => render(ConfirmDialog)}>打开</button>
      <Viewport/>
    </div>
  )
}

Page 卸载时,局部渲染器中未完成的渲染任务也会一并结束。


自定义渲染器

开发组件库业务子系统时,你可能希望对外暴露自己的渲染入口,而不是让用户依赖默认渲染器。此时可使用 createRenderer() 创建独立实例。

// your-lib/index.ts

import { createRenderer } from '@flow-render/react'

const [render, Viewport] = createRenderer()

export function LibProvider (props) {
  return (
    <>
      {props.children}
      <Viewport/>
    </>
  )
}

export function openDialog () {
  return render(Dialog)
}

这样用户使用时只需接入库提供的 Provider 和对应的方法,无需了解关于 Flow Render 的任何逻辑:

import { LibProvider, openDialog } from 'your-lib'

function App () {
  return (
    <LibProvider>
      <UserApp/>
      <button onClick={() => openDialog()}>打开</button>
    </LibProvider>
  )
}

这样便将渲染能力封装在库内部,对外提供更稳定、统一的 API。


取消渲染

手动取消渲染

某些高级场景下,你可能需要从外部中断 UI 流程,例如:

  • 超时自动关闭
  • 路由切换时终止
  • 用户主动取消整个流程

由于 render() 返回标准 Promise,你可以在适配器中自行暴露取消能力:

let cancel: () => void

const promise = render(Component, (resolve, reject) => {
  cancel = () => reject(new Error('Cancelled'))

  return {
    resolve,
    reject,
  }
})

// 需要时调用
cancel()

自动取消渲染

Viewport 卸载时(例如全局 Viewport 随应用销毁,或局部 Viewport 随组件销毁),所有未完成的渲染任务会自动 reject。如有必要可以通过 isCancelError 判断错误是否由自动取消引起。

import { render, isCancelError } from '@flow-render/react'

try {
  await render(Component)
} catch (error) {
  if (isCancelError(error)) {
    // 处理自动取消
    return
  }

  throw error
}

适用场景

Flow Render 特别适合以下交互:

  • 确认框 / 提示框
  • 表单弹窗
  • 选择器
  • 向导流程
  • 登录拦截
  • 权限确认
  • 任何需要“等待用户完成某一步再继续”的 UI 逻辑

例如,你可以将原本分散的交互写成线性流程:

async function postForm () {
  // 第一步:确认
  const confirmed = await render(ConfirmDialog, {
    title: '确认提交?'
  })

  if (!confirmed) {
    return
  }

  // 第二步:填写表单
  const formData = await render(FormDialog)

  // 第三步:提交
  await submit(formData)
}

相比传统的状态驱动写法,这种方式更易阅读、复用和维护。


设计理念

Flow Render 并非要替代框架原有的组件模型,而是为异步 UI 交互流程提供更自然的表达方式:

  1. 按需动态渲染
  2. 展示 UI 并等待用户操作
  3. 获取结果后继续后续逻辑

这几件事可以组织在同一段 async / await 代码中。

对于跨组件、跨层级、跨流程的交互,这种写法往往更直观。


Github: github.com/flow-render…

用 AI 实现图片懒加载,这也太简单了!

作者 wing98
2026年3月14日 16:20

在前端摸爬滚打了8年,以前做的主要是B端项目,所以很少能接触到性能优化方面的需求。

最近我们面向C端用户的产品首页图片比较多,产品在给老板演示时,发现图片加载速度很慢。

之前虽然设置了图片缓存,但架不住用户首次打开;而且之前的分页在最近一次调整中临时去掉了,导致首页需要加载50张高清大图,产品也没压缩,不卡才怪。当然也有我的锅,去掉分页后没有做懒加载。

所以,我决定对首页图片进行懒加载优化,不就是计算滚动top设置图片src吗?我都懒得写。

然后,我果断给TRAE下达了任务。

看着TRAE吭哧吭哧的干活,我悠闲的喝了一口咖啡。喝完之后,TRAE也差不多干完活了,我刷新了浏览器后滚动鼠标到底部,图片才开始加载。完美!

我开始看TRAE的代码,封装的真好啊,不过咋有点看不懂?IntersectionObserver是啥,Observer倒有点眼熟,连在一起是真不知道。

于是我点开了MDN的文档,被迫学习了一下!发现IntersectionObserver是一个非常方便的API,它可以监听元素是否进入视口。都不需要前端自己计算滚动top了,浏览器自己就可以监听。

果然科技是在进步的,就如我们现在指挥AI干活。

TRAE封装的代码如下(lazyload.ts):

/** * 图片懒加载工具函数 * 实现首次只渲染可视区域图片,滚动后加载其它图片 */interface LazyLoadOptions {  threshold?: number  rootMargin?: string}/** * 创建IntersectionObserver观察器 * @param callback 回调函数 * @param options 配置选项 * @returns IntersectionObserver实例 */const createObserver = (  callback: (entries: IntersectionObserverEntry[]) => void,  options: LazyLoadOptions = {}): IntersectionObserver => {  const defaultOptions: IntersectionObserverInit = {    threshold: options.threshold ?? 0.1,    rootMargin: options.rootMargin ?? '0px',  }  return new IntersectionObserver(callback, defaultOptions)}/** * 加载图片 * @param img 图片元素 */const loadImage = (img: HTMLImageElement): void => {  if (!img || !img.dataset.src) {    return  }  img.src = img.dataset.src  img.removeAttribute('data-src')}/** * 初始化图片懒加载 * @param selector 图片选择器 * @param options 配置选项 * @returns IntersectionObserver实例 */export const initLazyLoad = (  selector: string = 'img[lazy]',  options: LazyLoadOptions = {}): IntersectionObserver => {  const images = document.querySelectorAll<HTMLImageElement>(selector)    if (images.length === 0) {    console.warn('未找到需要懒加载的图片元素')    return createObserver(() => {})  }  const observer = createObserver((entries) => {    entries.forEach((entry) => {      if (entry.isIntersecting) {        const img = entry.target as HTMLImageElement        loadImage(img)        observer.unobserve(img)      }    })  }, options)  images.forEach((img) => {    if (img.dataset.src) {      observer.observe(img)    }  })  return observer}/** * 手动加载单张图片 * @param img 图片元素或图片元素ID */export const loadSingleImage = (img: HTMLImageElement | string): void => {  const imageElement = typeof img === 'string'     ? document.querySelector<HTMLImageElement>(img)     : img  if (imageElement) {    loadImage(imageElement)  }}/** * 销毁懒加载观察器 * @param observer IntersectionObserver实例 */export const destroyLazyLoad = (observer: IntersectionO

从“手工砌砖”到“魔法蓝图”:响应式驱动界面的诞生与实战

作者 Lee川
2026年3月14日 15:44

从“手工砌砖”到“魔法蓝图”:响应式驱动界面的诞生与实战

在编程的世界里,用户界面(UI)的构建方式经历了一场从“体力活”到“智力活”的深刻革命。这场革命的核心,就是从**“命令式地操作 DOM”转向“声明式地数据驱动”**。

为了让你彻底理解这一变革,我们将穿越时空,通过具体的代码对比,看看曾经的开发者是如何在“泥潭”中挣扎,而现在的我们又是如何利用响应式系统轻松驾驭界面的。


第一章:蛮荒时代——“手工砌砖”的痛苦

在互联网的早期(或者在使用原生 JavaScript/jQuery 的时代),浏览器只是一个简单的文档查看器。如果你想让界面上的文字变一下,或者增加一行列表,你必须像一个泥瓦匠一样,亲手去搬动每一块“砖头”(DOM 节点)。

1.1 场景:做一个简单的计数器

需求:页面上有一个数字显示当前计数,还有一个按钮,每点一次,数字加 1。

❌ 过去的做法(命令式 DOM 操作)

在那个年代,你的思维过程是这样的:

  1. 我要去 HTML 里找到那个显示数字的元素。
  2. 我要监听按钮的点击事件。
  3. 点击发生时,我要拿到当前的数字。
  4. 把数字加 1。
  5. 最关键的一步:我要手动把新数字写回那个元素里。

代码示例(原生 JavaScript):

<!-- 1. 定义 HTML 结构 -->
<div id="app">
  <h1 id="count-display">0</h1>
  <button id="increment-btn">点击加 1</button>
</div>

<script>
  // 2. 手动获取 DOM 元素(就像去仓库找砖头)
  const countDisplay = document.getElementById('count-display');
  const incrementBtn = document.getElementById('increment-btn');

  // 3. 定义一个变量存数据
  let count = 0;

  // 4. 手动绑定事件
  incrementBtn.addEventListener('click', () => {
    // 业务逻辑:数据加 1
    count = count + 1;
    
    // ⚠️ 痛苦之源:手动更新视图!
    // 如果忘了写这一行,界面永远不会变,但数据已经变了(状态不一致)
    // 如果页面有10个地方显示这个 count,你得改10次!
    countDisplay.innerText = count; 
    
    console.log("手动更新了 DOM,好累...");
  });
</script>
💡 痛点分析
  • 关注点偏移:你本该思考“点击后业务逻辑是什么”,却被迫花费大量精力在 getElementByIdinnerText 这些繁琐的 DOM 操作上。
  • 容易出错:如果你修改了 count 却忘了更新 countDisplay,界面就错了(数据与视图不同步)。
  • 难以维护:如果后来需求变了,要在三个不同的地方显示这个数字,你就得在三处地方都写上 xxx.innerText = count。代码变得像蜘蛛网一样乱。

第二章:黎明时刻——“魔法蓝图”的降临

随着 Vue、React 等框架的出现,世界变了。我们不再手动操作 DOM,而是引入了一位“管家”(响应式系统)。

核心理念你只管修改数据,界面自动会变。 你只需要画一张“蓝图”(模板),告诉框架:“这里显示 count”。至于 count 变了怎么更新界面?那是框架的事,与你无关。

2.1 同样的场景:计数器

需求:同上。

✅ 现在的做法(声明式 + 响应式)

现在的思维过程是这样的:

  1. 定义一个响应式数据 count
  2. 在模板里直接写 {{ count }}(这就是蓝图)。
  3. 点击时,只修改 count 的值。
  4. 结束。剩下的交给框架。

代码示例(Vue 3 风格):

<!-- 引入 Vue 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

<div id="app">
  <!-- 1. 声明式模板:直接告诉 Vue 这里显示 count -->
  <!-- 不需要给 h1 起 id,也不需要手动找它 -->
  <h1>{{ count }}</h1>
  
  <!-- 2. 事件绑定:点击直接调用函数 -->
  <button @click="increment">点击加 1</button>
</div>

<script>
  const { createApp, ref } = Vue;

  createApp({
    setup() {
      // 3. 定义响应式数据 (ref)
      // 这是一个有“魔法”的变量,它被修改时,所有用到它的地方都会收到通知
      const count = ref(0);

      // 4. 定义方法
      const increment = () => {
        // ⚡️ 核心时刻:只改数据!
        count.value++; 
        
        // 🎉 奇迹发生:
        // 你完全不需要写 document.getElementById...
        // 你完全不需要写 innerText = ...
        // Vue 检测到 count 变了,自动把页面上的 {{ count }} 更新为最新值
        console.log("数据已变,界面自动同步,真爽!");
      };

      // 把数据和方法暴露给模板使用
      return {
        count,
        increment
      };
    }
  }).mount('#app');
</script>
🚀 先进在哪里?
  1. 代码量减半:不需要找节点,不需要手动赋值。
  2. 单向数据流:数据是唯一的真理来源(Single Source of Truth)。你永远不会遇到“数据是 5,界面显示 4”这种 Bug。
  3. 可维护性极强:哪怕你在页面上写了 100 个 {{ count }},你也只需要改一次 count.value,所有地方瞬间同步更新。

第三章:进阶实战——列表的动态增删

如果说计数器只是热身,那么列表的动态增删才是真正体现“手工砌砖”与“魔法蓝图”差距的战场。

3.1 场景:待办事项列表

需求:有一个输入框,输入内容后回车,列表增加一项;点击列表项,该项删除。

❌ 过去的痛苦(原生 JS 实现逻辑推演)

如果用原生 JS 做这个,你需要处理:

  1. 监听输入框的 keydown 事件。
  2. 获取输入值,判空。
  3. 创建新的 li 元素 (document.createElement('li'))。
  4. 设置 li 的文本内容。
  5. 难点:给这个新生成的 li 里的“删除按钮”绑定点击事件(事件委托或直接绑定)。
  6. li 插入到 ul 中 (ul.appendChild(li))。
  7. 更难的是删除:点击删除时,要找到这个 li 对应的父节点,把它移除 (parent.removeChild(child)), 同时还要更新你内存里的数组数据,保持同步。

稍微想象一下代码长度:至少需要 30-40 行逻辑严密的 DOM 操作代码,稍有不慎就会内存泄漏或事件绑定失效。

✅ 现在的优雅(Vue 响应式实现)

在响应式世界里,我们只关心数组的变化。

<div id="todo-app">
  <h2>待办事项</h2>
  
  <!-- 双向绑定:输入框直接绑定到 newItem 变量 -->
  <input v-model="newItem" @keyup.enter="addTodo" placeholder="输入任务回车添加" />
  
  <!-- 列表渲染:v-for 指令 -->
  <!-- 意思是:items 数组里有几个元素,就生成几个 li -->
  <ul>
    <li v-for="(item, index) in items" :key="item.id">
      {{ item.text }} 
      <button @click="removeTodo(index)">删除</button>
    </li>
  </ul>
  
  <p v-if="items.length === 0">暂无任务,太轻松了!</p>
</div>

<script>
  const { createApp, ref } = Vue;

  createApp({
    setup() {
      const newItem = ref('');
      // 响应式数组
      const items = ref([
        { id: 1, text: '学习响应式原理' },
        { id: 2, text: '编写代码示例' }
      ]);

      // 添加逻辑:只操作数组
      const addTodo = () => {
        if (!newItem.value.trim()) return;
        // 往数组里 push 一个对象
        items.value.push({
          id: Date.now(),
          text: newItem.value
        });
        newItem.value = ''; // 清空输入框,界面自动清空
        
        // 🎉 此时:
        // 1. 新的 <li> 自动出现在列表中
        // 2. 删除按钮自动绑好了事件
        // 3. 如果列表从空变有,"暂无任务"提示自动消失
        // 全程无需触碰 DOM!
      };

      // 删除逻辑:只操作数组
      const removeTodo = (index) => {
        // 从数组里 splice 掉一项
        items.value.splice(index, 1);
        
        // 🎉 此时:
        // 对应的 <li> 自动从页面上移除
        // 事件监听器自动被清理(防止内存泄漏)
      };

      return {
        newItem,
        items,
        addTodo,
        removeTodo
      };
    }
  }).mount('#todo-app');
</script>

3.2 深度解析:为什么这很“先进”?

  1. 心智负担极低

    • 过去:你要同时维护“内存里的数组”和“页面上的 DOM 列表”,确保它们永远一致。这就像一边开车一边还要自己铺路。
    • 现在:你只维护“数组”。页面是数组的投影。数组变了,投影自然变。你只需要关注业务数据。
  2. 自动的事件管理

    • 在原生 JS 中,动态添加的 DOM 元素,你需要重新绑定事件,或者使用复杂的事件委托。
    • 在 Vue 中,@click 写在模板里,无论列表怎么变,新生成的元素天然就带着事件监听器,删除元素时监听器也自动销毁。
  3. 条件渲染的自动化

    • 注意代码中的 <p v-if="items.length === 0">
    • 当数组为空时,这段 HTML 自动出现;当数组有数据时,它自动消失。你不需要写 if/else 去控制 display: noneremoveChild

第四章:总结——从小白到架构师的思维跃迁

通过上面的对比,我们可以清晰地看到响应式驱动界面带来的巨大飞跃:

特性 传统 DOM 操作 (过去) 响应式数据驱动 (现在)
核心动作 查找节点 -> 修改属性 -> 插入/删除节点 修改数据变量
关注点 How (如何实现界面变化) What (数据应该是什么状态)
同步机制 手动同步,易出错 自动同步,永不失联
代码复杂度 随功能线性甚至指数增长 保持简洁,逻辑清晰
适合人群 需要精通底层细节的专家 专注于业务逻辑的开发者

给小白的建议

如果你刚开始学习前端,请忘掉 document.getElementById忘掉 innerHTML忘掉 手动添加事件监听器。

试着培养一种新的直觉:

  1. 数据先行:先想清楚我的页面需要哪些数据(比如 count, userList, isVisible)。
  2. 模板声明:在 HTML 里用 {{ }}v-for 把这些数据“画”出来。
  3. 事件驱动:在按钮点击时,只负责修改那些数据。

当你习惯了这种**“数据流动,界面随之起舞”**的感觉时,你就真正掌握了现代前端开发的精髓。这不仅仅是学会了一个框架,更是掌握了一种更高效、更优雅的构建数字世界的方法。

Pinia 高效指南:状态管理的最佳实践与性能陷阱

作者 wuhen_n
2026年3月13日 09:00

前言

在 Vue3 生态中,Pinia 已经成为官方推荐的状态管理库。它以其极简的 API、完美的 TypeScript 支持和与 Composition API 的无缝集成,彻底改变了我们管理全局状态的方式。然而,再好的工具如果使用不当,也会带来性能问题和维护噩梦。

本文将深入探讨 Pinia 的核心设计哲学,从基础的类型安全定义到高级性能优化,从常见陷阱到测试策略,帮助你在实际项目中真正驾驭这个强大的工具。

为什么我们需要Pinia?

从一个真实场景开始

想象我们正在开发一个电商网站,有这样一个需求:

<!-- 头部组件:显示用户名和购物车数量 -->
<template>
  <header>
    <div>欢迎您,{{ username }}</div>
    <div>购物车({{ cartCount }})</div>
  </header>
</template>

<!-- 商品列表组件:用户点击加入购物车 -->
<template>
  <div v-for="product in products">
    <h3>{{ product.name }}</h3>
    <button @click="addToCart(product)">加入购物车</button>
  </div>
</template>

<!-- 购物车组件:显示已选商品 -->
<template>
  <div v-for="item in cartItems">
    {{ item.name }} x {{ item.quantity }}
  </div>
</template>

这时候问题来了:当用户在商品列表页点击"加入购物车"时:

  • 头部组件需要更新购物车数量
  • 购物车组件需要显示新加的商品
  • 用户信息可能在多个地方使用

如果没有状态管理,我们可能会使用 事件总线props 层层传递,这样组件之间通信会变得极其复杂。

Pinia是什么?

简单来说,Pinia就是一个 中央数据仓库

┌─────────────────┐
│   Pinia Store   │
│  (数据仓库)      │
├─────────────────┤
│  用户信息        │
│  购物车数据      │
│  主题设置        │
└─────────────────┘
      ▲    ▲    ▲
      │    │    │
┌─────┴────┴────┴─────┐
│    所有组件直接访问  │
└─────────────────────┘

Pinia vs Vuex:为什么选Pinia?

在 Vue2 中,类似的功能我们通常使用 Vuex4 进行管理,为什么不继续使用 Vuex4 ,而要改用 Pinia 呢?让我们做个简单对比:

Vuex4 写法 - 繁琐的模板代码

const store = createStore({
  state: { count: 0 },
  mutations: {          // 为什么要多一层?
    increment(state) {
      state.count++
    }
  },
  actions: {            // 又要一层?
    increment({ commit }) {
      commit('increment')
    }
  }
})

Pinia 写法 - 简单直观

const useStore = defineStore('main', {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++      // 直接修改state,不需要mutations
    }
  }
})

Pinia的核心优势:

  • 更少的代码:比 Vuex4 少了 30% - 40% 的模板代码
  • 更好的 TypeScrip t支持:不用额外写类型定义
  • 更简单的API:只有stategettersactions
  • 模块化:每个 store 都是独立的,不需要额外的 module

快速上手 - 第一个Pinia Store

安装和配置

首先,我们需要在 Vue3 项目中安装 Pinia

npm install pinia
# 或者
yarn add pinia

然后在 main.js 中注册:

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

const app = createApp(App)
const pinia = createPinia()  // 创建Pinia实例

app.use(pinia)  // 使用Pinia
app.mount('#app')

创建第一个 Store

src/stores 目录下创建一个 counter.js 文件:

// stores/counter.js
import { defineStore } from 'pinia'

// 定义并使用一个store
export const useCounterStore = defineStore('counter', {
  // state:存储数据的地方
  state: () => ({
    count: 0,
    name: '计数器'
  }),
  
  // getters:计算属性,相当于computed
  getters: {
    // 自动推导返回类型
    doubleCount: (state) => state.count * 2,
    
    // 带参数的getter(返回一个函数)
    multiply: (state) => (times) => state.count * times
  },
  
  // actions:修改state的方法
  actions: {
    // 普通修改
    increment() {
      this.count++
    },
    
    // 带参数修改
    add(amount) {
      this.count += amount
    },
    
    // 异步操作
    async fetchAndSet() {
      // 模拟API调用
      const res = await fetch('/api/count')
      const data = await res.json()
      this.count = data.count
    }
  }
})

在组件中使用Store

现在,在任何组件中都可以使用这个计数器了:

<!-- Counter.vue -->
<template>
  <div class="counter">
    <h2>{{ store.name }}</h2>
    <p>当前值: {{ store.count }}</p>
    <p>双倍值: {{ store.doubleCount }}</p>
    <p>乘以3: {{ store.multiply(3) }}</p>
    
    <button @click="store.increment()">+1</button>
    <button @click="store.add(5)">+5</button>
    <button @click="handleAsync">异步获取</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

// 获取store实例
const store = useCounterStore()

// 异步操作
async function handleAsync() {
  await store.fetchAndSet()
}
</script>

深入理解 - Store的三个核心部分

State:数据存储

创建 state

State 就是存储数据的地方,类似于组件的 data 选项:

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    // 基础信息
    id: null,
    name: '',
    email: '',
    
    // 复杂数据
    preferences: {
      theme: 'light',
      language: 'zh-CN',
      notifications: true
    },
    
    // 集合类型
    permissions: [],
    
    // 状态标志
    isLoading: false,
    lastLogin: null
  })
})

访问和修改 state

// 获取store
const userStore = useUserStore()

// ✅ 读取state
console.log(userStore.name)
console.log(userStore.preferences.theme)

// ✅ 直接修改state(最简单的方式)
userStore.name = '张三'
userStore.preferences.theme = 'dark'

// ✅ 批量修改(推荐,只触发一次更新)
userStore.$patch({
  name: '李四',
  email: 'lisi@example.com'
})

// ✅ 更灵活的批量修改
userStore.$patch((state) => {
  state.name = '王五'
  state.preferences.theme = 'dark'
  state.permissions.push('admin')
})

// ✅ 重置state到初始值
userStore.$reset()

Getter:计算属性

创建 Getter

Getter 类似于组件的 computed 属性,用于派生出新的数据:

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    firstName: '张',
    lastName: '三',
    todos: [
      { text: '学习Pinia', done: true },
      { text: '写代码', done: false }
    ]
  }),
  
  getters: {
    // 基础getter
    fullName: (state) => `${state.firstName}${state.lastName}`,
    
    // 使用其他getter
    introduction: (state) => {
      return `我是${state.firstName}${state.lastName}`
    },
    
    // 带参数的getter(返回函数)
    getTodoByStatus: (state) => (done) => {
      return state.todos.filter(todo => todo.done === done)
    },
    
    // 统计完成数量
    completedCount: (state) => {
      return state.todos.filter(todo => todo.done).length
    },
    
    // 进度百分比
    progress: (state) => {
      const completed = state.todos.filter(todo => todo.done).length
      const total = state.todos.length
      return total === 0 ? 0 : Math.round((completed / total) * 100)
    }
  }
})

在组件中使用 getters

<template>
  <div>
    <h3>{{ userStore.fullName }}</h3>
    <p>进度: {{ userStore.progress }}%</p>
    
    <!-- 使用带参数的getter -->
    <div v-for="todo in userStore.getTodoByStatus(false)">
      {{ todo.text }} (未完成)
    </div>
  </div>
</template>

Action:业务逻辑

创建 action

Action 是修改 state 的地方,可以包含异步操作:

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    loading: false,
    error: null
  }),
  
  actions: {
    // 同步action
    setUser(user) {
      this.user = user
    },
    
    // 带参数的同步action
    updateUserInfo({ name, email }) {
      if (this.user) {
        this.user.name = name
        this.user.email = email
      }
    },
    
    // 异步action
    async login(credentials) {
      this.loading = true
      this.error = null
      
      try {
        // 调用登录API
        const response = await fetch('/api/login', {
          method: 'POST',
          body: JSON.stringify(credentials)
        })
        
        if (!response.ok) {
          throw new Error('登录失败')
        }
        
        const data = await response.json()
        this.user = data.user
        
        // 可以返回数据给组件
        return data.user
      } catch (error) {
        this.error = error.message
        throw error // 抛出错误,让组件处理
      } finally {
        this.loading = false
      }
    },
    
    // 组合多个action
    async logout() {
      try {
        await fetch('/api/logout')
      } finally {
        // 重置所有状态
        this.$reset()
      }
    }
  }
})

在组件中使用 action

import { useUserStore } from '@/stores/user'
import { ref } from 'vue'

const userStore = useUserStore()
const email = ref('')
const password = ref('')
const errorMsg = ref('')

async function handleLogin() {
  try {
    await userStore.login({
      email: email.value,
      password: password.value
    })
    // 登录成功,跳转到首页
    router.push('/dashboard')
  } catch (error) {
    errorMsg.value = error.message
  }
}

组合式风格 - 更现代的写法

从 Vue3 开始,组合式 API 成为主流。Pinia 也支持用组合式风格定义 store

基础组合式 Store

// stores/user.js (组合式风格)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  // ========== State:用ref定义 ==========
  const user = ref(null)
  const token = ref(localStorage.getItem('token'))
  const loading = ref(false)
  const error = ref(null)
  
  // ========== Getters:用computed定义 ==========
  const isLoggedIn = computed(() => !!token.value && !!user.value)
  
  const fullName = computed(() => {
    if (!user.value) return ''
    return `${user.value.lastName}${user.value.firstName}`
  })
  
  const isAdmin = computed(() => user.value?.role === 'admin')
  
  // 返回函数的getter
  const hasPermission = (permission) => {
    return computed(() => user.value?.permissions?.includes(permission))
  }
  
  // ========== Actions:普通函数 ==========
  function setUser(userData) {
    user.value = userData
  }
  
  async function login(credentials) {
    loading.value = true
    error.value = null
    
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials)
      })
      
      const data = await response.json()
      user.value = data.user
      token.value = data.token
      
      // 保存到localStorage
      localStorage.setItem('token', data.token)
      
      return data.user
    } catch (err) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }
  
  function logout() {
    user.value = null
    token.value = null
    localStorage.removeItem('token')
  }
  
  // 返回所有内容
  return {
    // state
    user,
    token,
    loading,
    error,
    
    // getters
    isLoggedIn,
    fullName,
    isAdmin,
    hasPermission,
    
    // actions
    setUser,
    login,
    logout
  }
})

为什么推荐组合式风格?

选项式风格:数据和逻辑分离

defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: { double: (state) => state.count * 2 },
  actions: { increment() { this.count++ } }
})

组合式风格:相关代码在一起,更易维护

defineStore('counter', () => {
  // 所有的相关代码都在这里
  const count = ref(0)
  const double = computed(() => count.value * 2)
  
  function increment() {
    count.value++
  }
  
  return { count, double, increment }
})

组合式风格的优势:

  • 更好的代码组织:相关的逻辑放在一起
  • 更容易复用:可以提取公共逻辑到组合式函数
  • 更灵活的TypeScript支持

实用技巧 - 解决常见问题

解构陷阱:为什么不能用解构?

这是新手很容易犯的错误:

import { useUserStore } from '@/stores/user'

// ❌ 错误:解构会失去响应式
const { name, email } = useUserStore()

// 当store中的name变化时,这里的name不会更新!

原理示意图

Store (响应式对象)
  ├── name (响应式属性)
  ├── email (响应式属性)
  └── login (普通函数)

直接解构:
const { name } = store
name --> 变成了普通变量,失去响应式

正确解构:storeToRefs

import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()

// ✅ 正确:使用storeToRefs保持响应式
const { name, email, isAdmin } = storeToRefs(userStore)

// ✅ actions可以直接解构(它们不是响应式的)
const { login, logout } = userStore

// 现在name是ref,修改会自动更新
console.log(name.value)  // 注意要加.value

storeToRefs 做了什么

// 简单理解它的原理
function storeToRefs(store) {
  const refs = {}
  
  for (const key in store) {
    const value = store[key]
    
    // 如果是响应式数据,转换为ref
    if (isRef(value) || isReactive(value)) {
      refs[key] = toRef(store, key)
    }
    // actions被忽略,保持原样
  }
  
  return refs
}

批量更新:避免多次渲染

// ❌ 错误:多次修改导致多次渲染
function addItems(items) {
  for (const item of items) {
    this.items.push(item)  // 触发一次渲染
    this.total += item.price  // 又触发一次
    this.count++  // 又一次触发
  }
}

// ✅ 正确:使用$patch批量更新
function addItems(items) {
  this.$patch((state) => {
    // 在$patch内部的所有修改只触发一次更新
    for (const item of items) {
      state.items.push(item)
      state.total += item.price
      state.count++
    }
  })
}

// ✅ 或者:先计算再赋值
function addItems(items) {
  const newItems = [...this.items, ...items]
  const total = newItems.reduce((sum, i) => sum + i.price, 0)
  
  // 一次性更新
  this.items = newItems
  this.total = total
  this.count = newItems.length
}

大型数据性能优化

当需要存储大量数据时:

// stores/data.js
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'

export const useDataStore = defineStore('data', () => {
  // ❌ 如果数据很大,ref会让所有属性都变成响应式
  const bigData = ref(fetchHugeDataset())
  
  // ✅ 使用shallowRef,只跟踪引用变化,内部属性不跟踪
  const bigDataOptimized = shallowRef(fetchHugeDataset())
  
  // 更新时整体替换
  function updateData(newData) {
    bigDataOptimized.value = newData  // 触发更新
    // 修改内部属性不会触发更新
    // bigDataOptimized.value[0].name = 'test' ❌ 不会触发渲染
  }
  
  return { bigDataOptimized, updateData }
})

避免在循环中使用store

<!-- ❌ 错误:每次循环都创建一个store实例 -->
<template>
  <div v-for="user in users" :key="user.id">
    <UserCard :store="useUserStore(user.id)" />
  </div>
</template>

解决方案:使用store工厂或传递ID

// stores/user.js
export const useUserStore = defineStore('user', () => {
  const users = ref(new Map()) // 用Map存储多个用户
  
  function getUser(id) {
    if (!users.value.has(id)) {
      users.value.set(id, null)
    }
    return computed({
      get: () => users.value.get(id),
      set: (value) => users.value.set(id, value)
    })
  }
  
  async function fetchUser(id) {
    const user = await api.getUser(id)
    users.value.set(id, user)
  }
  
  return { getUser, fetchUser }
})

// 在组件中使用
const userStore = useUserStore()
const user = userStore.getUser(props.userId)

watchEffect(() => {
  if (!user.value) {
    userStore.fetchUser(props.userId)
  }
})

循环依赖

// ❌ 错误:两个store相互引用
// storeA.js
export const useAStore = defineStore('a', () => {
  const bStore = useBStore()  // 依赖B
  const data = ref(bStore.someData)
  return { data }
})

// storeB.js
export const useBStore = defineStore('b', () => {
  const aStore = useAStore()  // 依赖A
  const data = ref(aStore.someData)
  return { data }
})

解决方案:提取共享逻辑

// 创建共享store:storeShared.js
export const useSharedStore = defineStore('shared', () => {
  const sharedData = ref({})
  return { sharedData }
})

// storeA.js
export const useAStore = defineStore('a', () => {
  const shared = useSharedStore()
  const data = computed(() => shared.sharedData.a)
  return { data }
})

// storeB.js
export const useBStore = defineStore('b', () => {
  const shared = useSharedStore()
  const data = computed(() => shared.sharedData.b)
  return { data }
})

Store 组合:1+1 > 2

一个 Store 中使用另一个 Store

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

export const useCartStore = defineStore('cart', () => {
  // 引入其他store
  const userStore = useUserStore()
  const productStore = useProductStore()
  
  // state
  const items = ref([])
  const coupon = ref(null)
  
  // getters - 组合多个store的数据
  const cartItems = computed(() => {
    return items.value.map(item => {
      // 从商品store获取详细信息
      const product = productStore.getProductById(item.productId)
      return {
        ...item,
        product,
        subtotal: product.price * item.quantity
      }
    })
  })
  
  const total = computed(() => {
    return cartItems.value.reduce((sum, item) => sum + item.subtotal, 0)
  })
  
  const canCheckout = computed(() => {
    // 同时依赖多个store
    return userStore.isLoggedIn && items.value.length > 0
  })
  
  // actions
  function addItem(productId, quantity = 1) {
    const existing = items.value.find(i => i.productId === productId)
    
    if (existing) {
      existing.quantity += quantity
    } else {
      items.value.push({ productId, quantity })
    }
    
    // 调用其他store的action
    productStore.reduceStock(productId, quantity)
  }
  
  async function checkout() {
    if (!canCheckout.value) {
      throw new Error('不能结算')
    }
    
    // 使用用户信息和购物车数据创建订单
    const order = {
      userId: userStore.user.id,
      items: items.value,
      total: total.value,
      coupon: coupon.value
    }
    
    // 调用订单API
    const result = await api.createOrder(order)
    
    // 清空购物车
    items.value = []
    
    return result
  }
  
  return {
    items,
    coupon,
    cartItems,
    total,
    canCheckout,
    addItem,
    checkout
  }
})

共享逻辑复用:工厂模式

// stores/factories/createListStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

/**
 * 创建一个通用的列表管理store
 * @param {string} id store的唯一标识
 * @param {Object} options 配置选项
 */
export function createListStore(id, options) {
  return defineStore(id, () => {
    // state
    const items = ref([])
    const loading = ref(false)
    const error = ref(null)
    const filters = ref({})
    
    // getters
    const total = computed(() => items.value.length)
    
    const filteredItems = computed(() => {
      let result = items.value
      
      // 应用自定义过滤逻辑
      if (options.filter) {
        result = result.filter(item => options.filter(item, filters.value))
      }
      
      return result
    })
    
    // actions
    async function fetchItems(params) {
      loading.value = true
      error.value = null
      filters.value = params || {}
      
      try {
        const data = await options.fetch(params)
        items.value = data
      } catch (err) {
        error.value = err.message
        throw err
      } finally {
        loading.value = false
      }
    }
    
    async function addItem(data) {
      if (!options.create) {
        throw new Error('create method not implemented')
      }
      
      const newItem = await options.create(data)
      items.value.push(newItem)
      return newItem
    }
    
    async function updateItem(id, data) {
      if (!options.update) {
        throw new Error('update method not implemented')
      }
      
      const updated = await options.update(id, data)
      const index = items.value.findIndex(i => i.id === id)
      if (index !== -1) {
        items.value[index] = updated
      }
      return updated
    }
    
    async function deleteItem(id) {
      if (!options.delete) {
        throw new Error('delete method not implemented')
      }
      
      await options.delete(id)
      items.value = items.value.filter(i => i.id !== id)
    }
    
    return {
      items,
      loading,
      error,
      filters,
      total,
      filteredItems,
      fetchItems,
      addItem,
      updateItem,
      deleteItem
    }
  })
}

// 使用工厂创建具体的store
// stores/users.js
import { createListStore } from './factories/createListStore'
import { userApi } from '@/api/user'

export const useUserStore = createListStore('users', {
  fetch: userApi.getUsers,
  create: userApi.createUser,
  update: userApi.updateUser,
  delete: userApi.deleteUser,
  filter: (user, filters) => {
    if (filters.keyword && !user.name.includes(filters.keyword)) {
      return false
    }
    if (filters.role && user.role !== filters.role) {
      return false
    }
    return true
  }
})

// 在组件中使用
const userStore = useUserStore()
await userStore.fetchItems({ keyword: '张' })

黄金法则与最佳实践

Store设计原则

原则 说明 示例
按业务划分 每个store管理一个业务领域 user、product、cart
扁平化 避免嵌套,保持简单 不要用modules
单一职责 一个store只做一件事 购物车不处理订单
可组合 store之间可以互相使用 购物车使用商品和用户

性能优化原则

原则 说明 示例
使用 storeToRefs 只解构需要的响应式数据 const { name } = storeToRefs(store)
actions 直接解构 actions 不是响应式的 const { login } = store
批量更新 $patch 批量更新,减少触发更新次数 store.$patch({ ... })
大型数据用 shallowRef 避免深度响应式 const data = shallowRef([])
避免循环依赖 store 之间不要相互引用 使用共享 store 解耦
按需加载 路由级别拆分 store 只在需要时 import

代码组织原则

推荐的 store 文件结构

stores/
├── index.js              # 统一导出
├── user.js               # 用户相关
├── product.js            # 商品相关
├── cart.js               # 购物车相关
└── factories/            # 工厂函数
    └── createListStore.js

推荐的 store 内部结构

export const useStore = defineStore('id', () => {
  // 1. state (ref)
  const data = ref(null)
  
  // 2. getters (computed)
  const computedData = computed(() => data.value)
  
  // 3. actions (functions)
  function action() {}
  
  // 4. return
  return { data, computedData, action }
})

常见错误检查清单

  • 是不是直接解构了 store
  • 是不是忘了用 storeToRefs
  • 是不是在循环中创建 store 实例?
  • 是不是有循环依赖?
  • 是不是用了太多响应式?
  • 是不是在 getter 中做了异步操作?

最终建议

Pinia 的成功在于它的简单类型安全。但简单不等于随意,类型安全不等于复杂。在实际项目中:

  1. 从简单的 store 开始,不要一开始就追求完美设计
  2. 遵循组合式风格,它更适合 Vue 3 的生态
  3. 注意性能陷阱,特别是 storeToRefs 的使用
  4. 充分利用 TypeScript,让类型系统帮你发现错误
  5. 测试核心逻辑,特别是涉及异步操作的 actions

结语

Pinia 只是工具,不是目标,不要为了用而用,而是要在真正需要共享状态的地方使用它。好的状态管理应该让业务代码更清晰,而不是增加复杂度。

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

VUE3 中的 Axios 二次封装与请求策略

作者 wuhen_n
2026年3月13日 08:59

前言

在现代前端应用中,网络请求是不可或缺的一部分。Axios 作为最流行的 HTTP 客户端,以其简洁的 API 和强大的功能赢得了开发者的青睐。然而,直接在每个组件中使用 Axios 会导致大量的代码冗余、错误处理混乱、难以维护等问题。

因此,我们需要对 Axios 进行二次封装,其核心价值在于:统一处理、集中配置、复用逻辑,把复杂的事情变得简单,把重复的事情变得自动化。

本文将从零开始,深入探讨如何构建一个健壮、易用、类型安全的请求层,涵盖拦截器、请求取消、重试机制、缓存策略等高级特性。

为什么要封装 Axios?

没有封装的代码长什么样?

在本文开篇之前,我们先来看一个没有封装的 Axios 请求是什么样的:

// 用户页面
async function getUser() {
  try {
    const res = await axios.get('http://localhost:3000/api/users', {
      headers: { token: localStorage.getItem('token') },
      timeout: 10000
    })
    user.value = res.data
  } catch (err) {
    if (err.response?.status === 401) {
      router.push('/login')
    }
    console.error('获取用户失败', err)
  }
}

// 商品页面
async function getProduct() {
  try {
    const res = await axios.get('http://localhost:3000/api/products', {
      headers: { token: localStorage.getItem('token') },
      timeout: 10000
    })
    product.value = res.data
  } catch (err) {
    if (err.response?.status === 401) {
      router.push('/login')
    }
    console.error('获取商品失败', err)
  }
}

这段代码有哪些问题呢?

  • 每个请求都需要重复配置 headerstimeout 等重复配置项
  • 每个请求都要重复获取和处理 tokenlocalStorage.getItem('token')
  • 每个请求都要写 try/catch 等错误处理
  • 当需要修改请求配置时,与之相关的所有文件都要修改

封装之后的代码长什么样?

二次封装后,所有的重复配置都只需要写一次:

// request.js
const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000
})

// 使用
import request from './request'
request.get('/users')
request.get('/products')

封装的核心价值

  • 统一配置:一次配置,到处使用
  • 统一处理:token 自动添加、错误统一处理
  • 复用逻辑:loading状态、重试机制等都可复用
  • 易于维护 :修改一处,生效全局

从零开始构建我们的请求层

第一层:基础配置

创建一个 request.js 文件,这是所有请求的基础:

// request.js
import axios from 'axios'

// 1. 创建axios实例
const request = axios.create({
  // 基础URL - 通过环境变量区分开发/生产
  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
  
  // 超时时间 - 10秒后自动断开
  timeout: 10000,
  
  // 请求头 - 默认配置
  headers: {
    'Content-Type': 'application/json'
  }
})

export default request

第二层:拦截器

拦截器就像机场的安检通道,每个请求和其响应都要经过检查:请求拦截器/响应拦截器:

// request.js
import { useUserStore } from '@/stores/user'

// 请求拦截器 - 请求发出前的处理
request.interceptors.request.use(
  (config) => {
    // 1. 获取用户token
    const userStore = useUserStore()
    
    // 2. 如果用户已登录,自动添加token
    if (userStore.token) {
      config.headers.Authorization = `Bearer ${userStore.token}`
    }
    
    // 3. GET请求添加时间戳,防止浏览器缓存
    if (config.method === 'get') {
      config.params = {
        ...config.params,
        _t: Date.now()
      }
    }
    
    return config
  },
  (error) => {
    // 请求配置出错时的处理
    return Promise.reject(error)
  }
)

// 响应拦截器 - 收到响应后的处理
request.interceptors.response.use(
  (response) => {
    // 直接返回数据部分,简化使用
    return response.data
  },
  (error) => {
    // 统一的错误处理
    if (error.response) {
      // 服务器返回了错误状态码
      switch (error.response.status) {
        case 401: // 未授权
          const userStore = useUserStore()
          userStore.logout() // 清除用户信息
          router.push('/login') // 跳转到登录页
          break
        case 403: // 禁止访问
          ElMessage.error('没有权限执行此操作')
          break
        case 404: // 资源不存在
          ElMessage.error('请求的资源不存在')
          break
        case 500: // 服务器错误
          ElMessage.error('服务器开小差了,请稍后再试')
          break
        default:
          ElMessage.error(error.response.data?.message || '请求失败')
      }
    } else if (error.request) {
      // 请求发出去了,但没有收到响应
      ElMessage.error('网络连接失败,请检查网络设置')
    } else {
      // 请求配置出错
      ElMessage.error('请求配置错误')
    }
    
    return Promise.reject(error)
  }
)

第三层:Loading 状态自动化

当我们在发送请求时,手动控制 loading 状态会很麻烦,可以让拦截器帮我们自动处理:

// stores/loading.js
import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useLoadingStore = defineStore('loading', () => {
  const count = ref(0) // 当前正在进行的请求数
  const isLoading = computed(() => count.value > 0) // 是否显示loading
  
  function add() {
    count.value++
  }
  
  function remove() {
    if (count.value > 0) {
      count.value--
    }
  }
  
  return { isLoading, add, remove }
})

在 request.js中,我们就可以使用上述 lodaing :

// request.js - 修改拦截器
import { useLoadingStore } from '@/stores/loading'

request.interceptors.request.use((config) => {
  // 如果不是手动禁用了loading
  if (!config.headers?.disableLoading) {
    const loadingStore = useLoadingStore()
    loadingStore.add()
  }
  return config
})

request.interceptors.response.use(
  (response) => {
    if (!response.config.headers?.disableLoading) {
      const loadingStore = useLoadingStore()
      loadingStore.remove()
    }
    return response
  },
  (error) => {
    if (!error.config?.headers?.disableLoading) {
      const loadingStore = useLoadingStore()
      loadingStore.remove()
    }
    return Promise.reject(error)
  }
)

在组件中使用:

<template>
  <div>
    <!-- 自动显示/隐藏loading -->
    <div v-if="loadingStore.isLoading" class="loading">加载中...</div>
    <div v-else>
      <!-- 页面内容 -->
    </div>
  </div>
</template>

<script setup>
import { useLoadingStore } from '@/stores/loading'

const loadingStore = useLoadingStore()

// 发起请求会自动显示loading
async function fetchData() {
  await request.get('/users')
}
</script>

实战技巧 - 解决常见痛点

场景1:请求取消,告别重复请求

当用户在使用搜索功能时,首先在搜索框输入"手机"发送搜索请求,此时请求还没返回;又将输入变成了"手机号",重新发送一次请求。此时应该取消第一个请求,只保留最新的一次请求:

// utils/CancelRequest.js
class CancelRequest {
  constructor() {
    // 存储所有pending状态的请求
    this.pendingMap = new Map()
  }
  
  // 生成请求的唯一标识
  getRequestKey(config) {
    const { method, url, params, data } = config
    return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&')
  }
  
  // 添加请求到pending列表
  addPending(config) {
    const key = this.getRequestKey(config)
    
    // 如果已有相同的请求,取消它
    if (this.pendingMap.has(key)) {
      const abort = this.pendingMap.get(key)
      abort() // 取消请求
      this.pendingMap.delete(key)
    }
    
    // 创建新的AbortController
    const controller = new AbortController()
    config.signal = controller.signal
    
    // 保存取消函数
    this.pendingMap.set(key, () => controller.abort())
  }
  
  // 请求完成后,从pending列表移除
  removePending(config) {
    const key = this.getRequestKey(config)
    if (this.pendingMap.has(key)) {
      this.pendingMap.delete(key)
    }
  }
  
  // 取消所有pending请求(这在页面切换时很有用)
  cancelAll() {
    this.pendingMap.forEach(cancel => cancel())
    this.pendingMap.clear()
  }
}

export const cancelRequest = new CancelRequest()

在拦截器中使用:

// request.js
import { cancelRequest } from './utils/CancelRequest'

request.interceptors.request.use((config) => {
  // 如果没有禁用取消功能
  if (!config.headers?.disableCancel) {
    cancelRequest.addPending(config)
  }
  return config
})

request.interceptors.response.use(
  (response) => {
    cancelRequest.removePending(response.config)
    return response
  },
  (error) => {
    // 如果是手动取消的请求,不抛出错误
    if (axios.isCancel(error)) {
      console.log('请求已取消')
      return new Promise(() => {}) // 返回pending的Promise
    }
    
    if (error.config) {
      cancelRequest.removePending(error.config)
    }
    return Promise.reject(error)
  }
)

// 路由切换时,取消所有请求
router.beforeEach((to, from, next) => {
  cancelRequest.cancelAll()
  next()
})

场景2:自动重试,提升用户体验

当网络不稳定时,我们需要自动重试功能,让用户无感知地完成操作,而不是简单地返回一句“网络异常,请稍后重试”:

// utils/retry.js
/**
 * 带重试功能的请求
 * @param {Function} requestFn 请求函数
 * @param {Object} options 配置选项
 */
export async function retryRequest(requestFn, options = {}) {
  const {
    retries = 3,           // 最大重试次数
    delay = 1000,          // 初始延迟(毫秒)
    factor = 2,            // 延迟增长倍数
    maxDelay = 30000,      // 最大延迟
    retryCondition = (error) => {
      // 默认重试条件:网络错误 或 5xx服务器错误
      return !error.response || error.response.status >= 500
    }
  } = options
  
  let attempt = 0
  
  while (attempt <= retries) {
    try {
      return await requestFn()
    } catch (error) {
      attempt++
      
      // 最后一次尝试失败,抛出错误
      if (attempt > retries) {
        throw error
      }
      
      // 检查是否应该重试
      if (!retryCondition(error)) {
        throw error
      }
      
      // 计算等待时间(指数退避)
      const waitTime = Math.min(delay * Math.pow(factor, attempt - 1), maxDelay)
      
      console.log(`请求失败,${waitTime}ms后第${attempt}次重试...`)
      
      // 等待后继续循环
      await new Promise(resolve => setTimeout(resolve, waitTime))
    }
  }
}

// 使用示例
async function fetchImportantData() {
  return retryRequest(
    () => request.get('/important-data'),
    {
      retries: 5,
      delay: 2000,
      onRetry: (attempt, error) => {
        // 可以在这里记录日志或通知用户
        console.log(`第${attempt}次重试`, error)
      }
    }
  )
}

场景3:数据缓存,减少不必要的请求

当用户频繁查看某个商品详情时,每次都要发送一次请求,这样既浪费资源,又慢,因此我们可以将数据缓存起来:

// utils/cache.js
class RequestCache {
  constructor() {
    this.cache = new Map()
  }
  
  /**
   * 设置缓存
   * @param {string} key 缓存键
   * @param {any} data 缓存数据
   * @param {number} ttl 过期时间(毫秒)
   */
  set(key, data, ttl = 60000) {
    this.cache.set(key, {
      data,
      expire: Date.now() + ttl
    })
  }
  
  /**
   * 获取缓存
   * @param {string} key 缓存键
   */
  get(key) {
    const item = this.cache.get(key)
    
    // 没有缓存
    if (!item) return null
    
    // 检查是否过期
    if (Date.now() > item.expire) {
      this.cache.delete(key)
      return null
    }
    
    return item.data
  }
  
  // 清除特定缓存
  delete(key) {
    this.cache.delete(key)
  }
  
  // 清除所有缓存
  clear() {
    this.cache.clear()
  }
}

export const requestCache = new RequestCache()

// 封装带缓存的请求
async function requestWithCache(url, options = {}) {
  const { cacheTTL = 60000, ...restOptions } = options
  
  // 只有GET请求才使用缓存
  if (restOptions.method && restOptions.method !== 'GET') {
    return request(url, restOptions)
  }
  
  // 生成缓存键
  const cacheKey = `${url}:${JSON.stringify(restOptions.params)}`
  
  // 检查缓存
  const cached = requestCache.get(cacheKey)
  if (cached) {
    console.log('使用缓存数据:', cacheKey)
    return cached
  }
  
  // 发起真实请求
  const data = await request(url, restOptions)
  
  // 存入缓存
  requestCache.set(cacheKey, data, cacheTTL)
  
  return data
}

TypeScript 加持 - 让代码更可靠

自定义类型系统

// types/api.d.ts
// 通用响应格式
export interface ApiResponse<T = any> {
  code: number        // 业务状态码
  message: string     // 提示信息
  data: T            // 实际数据
  timestamp?: number  // 时间戳
}

// 分页参数
export interface PaginationParams {
  page: number        // 当前页码
  pageSize: number    // 每页条数
  sort?: string       // 排序字段
  order?: 'asc' | 'desc' // 排序方式
}

// 分页结果
export interface PaginatedResult<T> {
  list: T[]           // 数据列表
  total: number       // 总条数
  page: number        // 当前页码
  pageSize: number    // 每页条数
  totalPages: number  // 总页数
}

// 扩展的请求配置
export interface RequestConfig {
  url: string
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
  data?: any
  params?: any
  headers?: Record<string, string>
  
  // 自定义选项
  disableLoading?: boolean  // 是否禁用loading
  disableCancel?: boolean   // 是否禁用自动取消
  cacheTTL?: number        // 缓存时间(毫秒)
  retries?: number         // 重试次数
}

创建类型安全的API模块

// api/user.ts
import request from '@/request'
import type { PaginationParams, PaginatedResult } from '@/types/api'

// 用户类型定义
export interface User {
  id: number
  name: string
  email: string
  avatar: string
  role: 'admin' | 'user'
  status: 'active' | 'inactive'
  createdAt: string
  updatedAt: string
}

export interface CreateUserDto {
  name: string
  email: string
  password: string
  role?: 'admin' | 'user'
}

export interface UpdateUserDto extends Partial<CreateUserDto> {
  status?: 'active' | 'inactive'
}

export interface UserListParams extends PaginationParams {
  keyword?: string
  role?: string
  status?: string
}

// 用户API模块
export const userApi = {
  // 获取用户列表
  getList: (params: UserListParams) => 
    request.get<PaginatedResult<User>>('/users', { params }),
  
  // 获取单个用户
  getDetail: (id: number) => 
    request.get<User>(`/users/${id}`),
  
  // 创建用户
  create: (data: CreateUserDto) => 
    request.post<User>('/users', data),
  
  // 更新用户
  update: (id: number, data: UpdateUserDto) => 
    request.put<User>(`/users/${id}`, data),
  
  // 删除用户
  delete: (id: number) => 
    request.delete(`/users/${id}`),
  
  // 修改状态
  updateStatus: (id: number, status: User['status']) => 
    request.patch(`/users/${id}/status`, { status })
}

在组件中使用

<script setup lang="ts">
import { ref } from 'vue'
import { userApi } from '@/api/user'
import type { User, UserListParams } from '@/api/user'

const users = ref<User[]>([])
const loading = ref(false)

const params = ref<UserListParams>({
  page: 1,
  pageSize: 10,
  keyword: ''
})

async function loadUsers() {
  loading.value = true
  try {
    const result = await userApi.getList(params.value)
    users.value = result.list
  } finally {
    loading.value = false
  }
}

// 完全的类型提示和自动补全!
async function handleCreate() {
  const newUser = await userApi.create({
    name: '张三',
    email: 'zhangsan@example.com',
    password: '123456',
    role: 'user'
  })
  users.value.push(newUser)
}
</script>

封装的度 - 如何把握封装分寸?

封装层次图

graph TB
    subgraph "业务层"
        A[业务组件]
    end
    
    subgraph "API层"
        B[API模块]
    end
    
    subgraph "基础层"
        C[请求实例]
        D[拦截器]
        E[工具函数]
    end
    
    A --> B
    B --> C
    C --> D
    D --> E

封装原则

原则一:够用即可

不要过度设计,根据项目规模选择合适的封装程度:

// ✅ 小型项目:简单封装就够了
const request = axios.create({ baseURL: '/api' })

// ✅ 中型项目:添加拦截器、类型定义
request.interceptors.response.use(/* 错误处理 */)

// ✅ 大型项目:完整的缓存、重试、监控机制

原则二:可配置性

提供出口,让特殊场景可以绕过封装:

// 通过配置项控制
await request.get('/important-data', {
  headers: {
    disableLoading: true,  // 不显示loading
    disableCancel: true,   // 不自动取消
    disableRetry: true     // 不重试
  }
})

原则三:渐进增强

从简单开始,逐步完善:

// 第一阶段:基础封装
export const api = {
  getUser: () => request.get('/user')
}

// 第二阶段:添加类型
export const api = {
  getUser: (): Promise<User> => request.get('/user')
}

// 第三阶段:添加高级特性
export const api = {
  getUser: () => retryRequest(
    () => requestWithCache('/user'),
    { retries: 3 }
  )
}

封装的检查清单

检查项 是否必需 说明
基础配置 baseURL、超时、请求头
错误处理 统一错误提示、状态码处理
Token管理 自动附加、过期处理
Loading状态 推荐 提升用户体验
TypeScript 推荐 类型安全、开发体验
请求取消 看场景 搜索、标签切换等
数据缓存 看场景 频繁访问的静态数据
自动重试 看场景 网络不稳定时

完整目录结构

src/
├── api/
│   ├── index.ts           # API统一出口
│   ├── user.ts            # 用户模块
│   ├── product.ts         # 商品模块
│   └── order.ts           # 订单模块
├── utils/
│   ├── request.ts         # 请求核心
│   ├── cache.ts           # 缓存工具
│   ├── retry.ts           # 重试工具
│   └── cancel.ts          # 取消工具
├── types/
│   └── api.d.ts           # 类型定义
└── stores/
    └── loading.ts         # loading状态

最终建议

Axios 封装没有标准答案,关键在于根据项目规模和团队习惯找到平衡点

  • 小型项目:简单的拦截器 + 类型定义就够了
  • 中型项目:需要请求取消、错误统一处理
  • 大型项目:完整的缓存、重试、监控机制

结语

封装不是为了炫技,而是为了让代码更简单,让开发更高效。一个好的封装应该让 90% 的场景变得简单,同时给 10% 的特殊场景留出出口。希望这篇文章能帮助我们构建适合自己的请求层。记住,最好的封装是让使用它的人感受不到封装的存在。

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

Vue3 接入 Google 登录:极简教程

作者 wing98
2026年3月14日 15:10

公司目前做的是一款激光雕刻产品,主要用于出口海外,需要开发一个web社区网站用于桌面端模型生成后发布到社区进行分享和交流。说到出海产品,google第三方登录是必须接入的,这两天和后端一起开发完成了此功能,现将流程大概梳理如下。

首先当然是看Google的OAuth 2.0文档,了解其流程和参数。

文档地址:developers.google.com/identity/pr…

第一步:在Google API Console创建 OAuth 2.0 凭据

第二步:接入方式和流程

我们可以看到文档提供了多种应用类型的接入方式,对于web来说主要是红框里的两种。

1、适用于服务器端 Web 应用

我们目前采用的方式,需要后端存储google用户信息。

接入流程:前端唤起 Google 授权 → 前端获取授权码 → 后端用授权码换 token → 验证用户信息并返回自有 token

2、适用于 JavaScript Web 应用

主要前端完成google登录,后端只需要接收前端返回的token进行验证即可。

接入流程:用户点击登录 → 授权 -> 前端直接获得id_token + 用户信息 -> id_token 发给后端验证 → 完成登录。

另外对于前端交互来说均有两种可供选择:

1、popup模式:在当前页面弹窗授权,体验更友好。

2、redirect模式:跳转新页面授权后重定向回来。

popup模式,采用vue3-google-login第三方依赖。需要注意的点:

1、前端测试通过code换取access_token时,postman需要设置请求头“Content-Type: application/x-www-form-urlencoded”,否则会报错“invalid_grant”。

2、Google凭据那里不需要配置重定向URI,且后端用code换取access_token所传的参数redirect_uri应该为“postmessage”,否则会报错“redirect_uri_mismatch”。

3、code不能重复使用。

以下是直接可用的前端代码(popup模式):

GoogleLoginBtn.vue

<template>  <GoogleLogin    :client-id="googleClientId"    popup-type="CODE"    :callback="handleGoogleSuccess"    :error="handleGoogleError"  >    <img class="google-login-icon" src="@/assets/icons/google.png" alt="google-login">    <button v-if="false" class="google-login-button" type="button">      <span class="google-mark">G</span>      <span class="google-label">{{ buttonLabel }}</span>    </button>  </GoogleLogin></template><script setup lang="ts">import { ElMessage } from 'element-plus'import { GoogleLogin, type CallbackTypes } from 'vue3-google-login'import { useUserStore } from '@/stores/user'interface Props {  buttonLabel?: string}interface Emits {  (e: 'success'): void  (e: 'error'): void}withDefaults(defineProps<Props>(), {  buttonLabel: 'Google 账号快捷登录',})const emit = defineEmits<Emits>()const userStore = useUserStore()const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID as string | undefinedconst handleGoogleSuccess = async (response: CallbackTypes.CodePopupResponse) => {  if (!response?.code) {    ElMessage.error('未获取到 Google 授权码')    emit('error')    return  }  const success = await userStore.userLoginByGoogleCode(response.code)  if (success) {    emit('success')    return  }  emit('error')}const handleGoogleError = (_error: unknown) => {  ElMessage.error('Google 授权失败,请重试')  emit('error')}</script><style scoped lang="scss">.google-login-button {  width: 176px;  height: 44px;  border: 1px solid #d9d9d9;  border-radius: 10px;  background: #fff;  display: flex;  align-items: center;  justify-content: center;  gap: 8px;  cursor: pointer;  transition: all 0.2s ease;}.google-login-button:hover {  border-color: #c7c7c7;  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);  transform: translateY(-1px);}.google-mark {  font-size: 18px;  font-weight: 700;  color: #ea4335;}.google-label {  font-size: 13px;  color: #333;  white-space: nowrap;}.google-login-icon {  width: 48px;  height: 48px;  cursor: pointer;}</style>

redirect模式,期间由于踩了上面提的popup模式的坑,也改过一版redirect模式,没有采用第三方依赖。

以下是直接可用的前端代码(redirect模式):

GoogleLoginBtn.vue

<template>  <button class="google-login-trigger" type="button" @click="handleGoogleLogin">    <img class="google-login-icon" src="@/assets/icons/google.png" alt="google-login" />    <span class="sr-only">{{ buttonLabel }}</span>  </button></template><script setup lang="ts">import { ElMessage } from 'element-plus'interface Props {  buttonLabel?: string}interface Emits {  (e: 'success'): void  (e: 'error'): void}withDefaults(defineProps<Props>(), {  buttonLabel: 'Google 账号快捷登录',})const emit = defineEmits<Emits>()const GOOGLE_STATE_KEY = 'google_oauth_state'const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID as string | undefinedconst googleRedirectUri = (import.meta.env.VITE_GOOGLE_REDIRECT_URI as string | undefined)  || `${window.location.origin}/front/community/home`const createOAuthState = () => {  if (window.crypto?.randomUUID) {    return window.crypto.randomUUID()  }  return `${Date.now()}_${Math.random().toString(36).slice(2)}`}const handleGoogleLogin = () => {  if (!googleClientId) {    ElMessage.error('未配置 Google Client ID,无法登录')    emit('error')    return  }  const state = createOAuthState()  sessionStorage.setItem(GOOGLE_STATE_KEY, state)  const query = new URLSearchParams({    client_id: googleClientId,    redirect_uri: googleRedirectUri,    response_type: 'code',    scope: 'openid profile email',    state,  })  window.location.assign(`https://accounts.google.com/o/oauth2/v2/auth?${query.toString()}`)}</script><style scoped lang="scss">.google-login-trigger {  display: inline-flex;  align-items: center;  justify-content: center;  border: none;  background: transparent;  padding: 0;  cursor: pointer;}.google-login-icon {  width: 48px;  height: 48px;  cursor: pointer;}.sr-only {  position: absolute;  width: 1px;  height: 1px;  padding: 0;  margin: -1px;  overflow: hidden;  clip: rect(0, 0, 0, 0);  white-space: nowrap;  border: 0;}</style>

App.vue

<template>  <Layout :show-top-bar="showTopBar">    <router-view />  </Layout></template><script setup lang="ts">import Layout from './components/Layout.vue'import { useRoute, useRouter } from 'vue-router'import { computed, onMounted, nextTick, ref, watch } from 'vue'import { useI18n } from 'vue-i18n'import { ElMessage } from 'element-plus'import { useUserStore } from '@/stores/user'const route = useRoute()const router = useRouter()const userStore = useUserStore()const { t } = useI18n()const isProcessingGoogleOAuth = ref(false)const GOOGLE_STATE_KEY = 'google_oauth_state'const showTopBar = computed(() => {  const from = route.query.from as string  localStorage.setItem('from', from || 'community')  return from !== 'pc-home'})const getSingleQueryValue = (value: unknown) => {  if (Array.isArray(value)) {    return value[0] || ''  }  return typeof value === 'string' ? value : ''}const clearGoogleOAuthQuery = async () => {  const nextQuery = { ...route.query }  delete nextQuery.code  delete nextQuery.scope  delete nextQuery.authuser  delete nextQuery.prompt  delete nextQuery.state  delete nextQuery.error  delete nextQuery.error_description  await router.replace({    path: route.path,    query: nextQuery,  })}const processGoogleOAuthCallback = async () => {  const code = getSingleQueryValue(route.query.code)  const oauthError = getSingleQueryValue(route.query.error)  const incomingState = getSingleQueryValue(route.query.state)  if ((!code && !oauthError) || isProcessingGoogleOAuth.value) {    return  }  isProcessingGoogleOAuth.value = true  try {    if (oauthError) {      ElMessage.error(`Google OAuth failed: ${oauthError}`)      return    }    const expectedState = sessionStorage.getItem(GOOGLE_STATE_KEY)    sessionStorage.removeItem(GOOGLE_STATE_KEY)    if (expectedState && expectedState !== incomingState) {      ElMessage.error('Google OAuth state validation failed')      return    }    const success = await userStore.userLoginByGoogleCode(code)    if (success) {      ElMessage.success(t('auth.loginSuccess'))    }  } finally {    await clearGoogleOAuthQuery()    isProcessingGoogleOAuth.value = false  }}watch(  () => route.fullPath,  () => {    void processGoogleOAuthCallback()  },  { immediate: true })onMounted(async () => {  await nextTick()  const from = route.query.from as string  console.log('route.query.from:', from)})</script><style scoped>* {  margin: 0;  padding: 0;  box-sizing: border-box;}body {  font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;  line-height: 1.5;  color: #333;  background-color: #f5f5f5;}.content-placeholder {  text-align: center;  padding: 60px 20px;  color: #666;}.content-placeholder h1 {  font-size: 36px;  margin-bottom: 20px;  color: #333;}</style>

在 Vue 2.6 微前端架构中,我们为什么放弃了 Vuex 管理页面状态?

2026年3月14日 13:01

在 Vue 2.6 微前端架构中,我们为什么放弃了 Vuex 管理页面状态

背景:一个越来越"重"的页面

我们团队用 single-spa 搭了一套微前端架构,主技术栈是 Vue 2.6 + Element UI。系统里有不少复杂页面——转化漏斗分析详情、行为数据分析仪表盘、事件流程分析……这类页面的共同特点是:

  • 组件层级深,一个页面拆成 10+ 子组件很常见
  • 组件间通信频繁,筛选条件变了、Tab 切了、日期选了,好几个组件要同步响应
  • 状态生命周期跟页面走,进来要初始化,离开要清干净

一开始我们用 Vuex 管这些状态,很快就发现不对劲。

Vuex 管页面状态,哪里不对?

第一个问题:状态残留。 用户从漏斗详情页跳到事件分析页,再跳回来,Vuex 里上一次的筛选条件还在。你说用 beforeDestroy 里手动 reset?可以,但每个页面都要写一遍,写漏了就是 bug。

第二个问题:命名空间膨胀。 每个复杂页面一个 Vuex module,funnelDetail/setFilterseventAnalysis/setFiltersbehaviorDashboard/setFilters……全局 store 越来越臃肿,而这些 module 99% 的时间都不需要存在。

第三个问题:Vuex 的仪式感太重。 改一个状态要经过 commit → mutation → state,对于页面内部的交互状态来说,这个链路完全多余。筛选条件变了就该直接改,不需要走 mutation 审计。

我们试过的其他方案

provide / inject——只能传数据,不能传事件。组件 A 想通知组件 B "筛选变了,你该刷新了",provide/inject 做不到。

全局 EventBus($micRootBus ——我们微前端里有一个全局事件总线。但拿它做页面内通信,三个致命问题:

// 1. 命名冲突:漏斗详情和事件分析都有 filter:change
this.$micRootBus.$emit('filter:change', filters) // 谁的 filter?

// 2. 内存泄漏:每个 $on 都要手动 $off,页面销毁时漏一个就泄漏
beforeDestroy() {
  this.$micRootBus.$off('funnelDetail:filter:change', this.handler1)
  this.$micRootBus.$off('funnelDetail:tab:change', this.handler2)
  this.$micRootBus.$off('funnelDetail:date:change', this.handler3)
  // ... 8 个地方全要清,漏一个就寄
}

// 3. 边界模糊:事件扩散到全局,debug 时不知道谁在监听

组件 data + props 层层传递——5 层组件传一个筛选条件,中间 3 层只是当传话筒。经典的 props drilling 地狱。

每个方案都差点意思。我们需要的是一个页面级别的运行时上下文——状态、通信、副作用,全部限定在当前页面的作用域里,页面销毁时一键回收。

于是我们造了 vue-page-store

核心思路很简单:用一个隐藏的 Vue 实例承载响应式 state + computed getters,再加一个闭包隔离的事件总线,生命周期绑定在一起。

npm install vue-page-store

定义一个页面级 Store

import { definePageStore } from 'vue-page-store'

export const useFunnelStore = definePageStore('funnelDetail', {
  state: () => ({
    filters: { dateRange: [], platform: '' },
    loading: false,
    funnelSteps: [],
  }),

  getters: {
    isReady() {
      return !this.loading && this.funnelSteps.length > 0
    },
  },

  actions: {
    async fetchData() {
      this.loading = true
      try {
        this.funnelSteps = await api.getFunnelSteps(this.filters)
      } finally {
        this.loading = false
      }
    },
  },
})

API 风格完全对齐 Pinia:state / getters / actions,用过 Pinia 的人零学习成本。

组件中使用

const store = useFunnelStore()

// 直接读
store.filters
store.isReady

// 直接改
store.filters = newFilters

// 调 action
store.fetchData()

// 批量更新
store.$patch({ loading: true, filters: newFilters })

没有 commit,没有 mutation,没有 mapState。直接属性访问,直接赋值。

页面内通信:作用域隔离的事件

这是 vue-page-store 和 Pinia 最大的区别。我们内置了一个页面作用域级的事件总线

// 组件 A —— 发射事件
store.$emit('filter:change', newFilters)

// 组件 B —— 监听事件
const off = store.$on('filter:change', (filters) => {
  this.applyFilters(filters)
})

重点来了: _listeners 是闭包内的私有变量,每个 store 实例独立一份。 漏斗详情的 filter:change 和事件分析的 filter:change 完全隔离,互不干扰。

为什么不拆成独立的 EventBus?因为生命周期要跟 store 绑定。$destroy 的时候自动清空所有 listeners:

store.$destroy = () => {
  // 清空事件 —— 不会泄漏
  Object.keys(_listeners).forEach(key => delete _listeners[key])
  // 销毁 Vue 实例 —— 回收 watchers
  vm.$destroy()
  // 移除注册 —— 下次进来是全新的
  storeRegistry.delete(id)
}

调用方(子组件)只需要注入 store 就能通信,不需要感知全局 Bus,不需要手动 $off,不需要加命名前缀。

页面销毁:一行代码全部回收

// 页面根组件
beforeDestroy() {
  useFunnelStore().$destroy()
}

state、getters、watchers、事件监听——全部清干净。下次进这个页面,又是一个全新的 store。

它不是 Pinia 的替代品

这一点必须说清楚。vue-page-store 解决的是 Vuex / Pinia 覆盖不到的那个中间地带:

Vuex Pinia vue-page-store
作用域 全局 全局 页面级
生命周期 应用级 应用级 页面级($destroy 回收)
事件通信 内置 emit/emit/on(作用域隔离)
Vue 2.6 支持 ⚠️ 需 @vue/composition-api ✅ 原生支持
适合管什么 用户信息、权限、全局配置 同左 复杂页面内部状态

推荐组合:Vuex 管全局,vue-page-store 管页面。 各管各的,互不干扰。

声明式 watch:页面级副作用的自动管理

除了状态和事件,页面里还有一类东西需要管理——副作用。比如"查询时间范围变了,自动判断是否按小时查询":

export const useFunnelStore = definePageStore('funnelDetail', {
  state: () => ({ /* ... */ }),

  getters: {
    isQueryByHour() {
      const range = this.filters?.dateRange
      return (new Date(range[1]) - new Date(range[0])) / 3600000 <= 24
    },
  },

  watch: {
    'isQueryByHour'(val) {
      if (!val) this.tabTime = 'hour'
    },
  },
})

声明式写法,定义的时候绑上去,$destroy 的时候跟着 Vue 实例一起销毁。不需要手动 $watch 再手动 unwatch

实现原理:100 行代码

核心实现非常简单,整个库不到 200 行,核心逻辑 100 行出头:

  1. new Vue({ data: { $$state }, computed }) —— 一个隐藏的 Vue 实例,承载响应式和 computed
  2. Object.defineProperty 代理 —— 把 state 和 getters 暴露到 store 对象上
  3. 闭包内的 _listeners 对象 —— 作用域隔离的事件总线
  4. storeRegistry Map —— 保证同一个 id 只有一个实例

没有黑魔法,没有额外依赖,gzip 后不到 3KB。

最后

如果你也在用 Vue 2.6 + 微前端架构,遇到了页面级状态管理的痛点,可以试试:

npm install vue-page-store

Vue 3 项目推荐用 Pinia,这个库专为 Vue 2.6 场景设计。

如果对你有帮助,欢迎 star ⭐️,有问题直接提 issue。

Vite 工程化实战 | 从 0 配置一个企业级前端项目(按需引入 / 环境变量 / 打包优化)

作者 代码煮茶
2026年3月14日 11:38

零、为什么我们要“折腾”环境?

还记得你第一次用 create-vue 脚手架时的感受吗?一行命令,项目就跑起来了,那叫一个爽!

但是!当你真正进入公司项目,你会发现:

// 理想中的项目
npm run dev // 启动,完事!

// 现实中的项目
npm run dev // 报错!Node版本不对
npm run build // 报错!内存溢出
npm run lint // 报错!代码格式不对
npm run test // 报错!环境变量没配

这时候你才明白:脚手架给你的是“毛坯房”,企业级项目需要的是“精装修”

今天,我们就从一个空文件夹开始,一步步搭建一个企业级 Vue3 + Vite 项目。这不是简单的“搭环境”,而是“搭项目”!

一、项目初始化:从零开始的艺术

1.1 创建项目(这次不用脚手架)

# 创建项目目录
mkdir vite-enterprise-demo
cd vite-enterprise-demo

# 初始化 package.json
npm init -y

# 安装核心依赖
npm install vue@latest
npm install -D vite @vitejs/plugin-vue typescript vue-tsc

# 创建项目结构
mkdir -p src/{assets,components,views,router,store,utils,styles,types}
touch index.html vite.config.ts tsconfig.json src/main.ts src/App.vue

现在的项目结构应该是这样:

vite-enterprise-demo/
├── src/
│   ├── assets/        # 静态资源
│   ├── components/     # 组件
│   ├── views/         # 页面
│   ├── router/        # 路由
│   ├── store/         # 状态管理
│   ├── utils/         # 工具函数
│   ├── styles/        # 全局样式
│   ├── types/         # TypeScript类型
│   ├── main.ts        # 入口文件
│   └── App.vue        # 根组件
├── index.html         # 入口HTML
├── vite.config.ts     # Vite配置
├── tsconfig.json      # TypeScript配置
└── package.json       # 项目配置

1.2 配置入口文件

<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <link rel="icon" href="/favicon.ico">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vite企业级项目实战</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'

// 创建应用实例
const app = createApp(App)

// 挂载应用
app.mount('#app')
<!-- src/App.vue -->
<template>
  <div class="app">
    <h1>🚀 Vite企业级项目实战</h1>
    <p>从0开始,搭建一个生产可用的项目</p>
  </div>
</template>

<script setup lang="ts">
// 这里写逻辑
</script>

<style scoped>
.app {
  text-align: center;
  padding: 2rem;
  color: #2c3e50;
}
</style>

1.3 配置 Vite

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

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  
  // 路径别名
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '@components': resolve(__dirname, 'src/components'),
      '@views': resolve(__dirname, 'src/views'),
      '@utils': resolve(__dirname, 'src/utils'),
      '@styles': resolve(__dirname, 'src/styles')
    }
  },
  
  // 开发服务器配置
  server: {
    port: 3000,
    open: true, // 自动打开浏览器
    cors: true, // 允许跨域
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/api/, '')
      }
    }
  },
  
  // 构建配置
  build: {
    target: 'es2015',
    outDir: 'dist',
    assetsDir: 'assets',
    assetsInlineLimit: 4096, // 小于4kb的图片转base64
    sourcemap: false, // 不生成sourcemap
    reportCompressedSize: false, // 关闭压缩大小报告
    chunkSizeWarningLimit: 500 // 块大小警告限制
  }
})
// package.json 添加脚本
{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "type-check": "vue-tsc --noEmit"
  }
}

试试运行:

npm run dev

看到页面了吗?恭喜!你已经从0开始搭建了一个Vite项目!

二、TypeScript 配置:告别 any 恐惧症

2.1 配置 tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM"],
    "types": ["vite/client"],
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@views/*": ["src/views/*"],
      "@utils/*": ["src/utils/*"],
      "@styles/*": ["src/styles/*"]
    },
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
  "exclude": ["node_modules", "dist"]
}

2.2 添加类型声明

// src/types/shims-vue.d.ts
declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

// src/types/env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_APP_TITLE: string
  readonly VITE_API_BASE_URL: string
  readonly VITE_ENABLE_MOCK: string
  // 更多环境变量...
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

三、环境变量配置:一套代码,多套环境

3.1 环境变量文件

# .env                # 所有环境共用
# .env.local          # 本地覆盖(不提交)
# .env.development    # 开发环境
# .env.production     # 生产环境
# .env.test           # 测试环境
# .env.development
VITE_APP_TITLE=开发环境
VITE_API_BASE_URL=http://localhost:8080/api
VITE_ENABLE_MOCK=true
VITE_LOG_LEVEL=debug

# .env.production
VITE_APP_TITLE=生产环境
VITE_API_BASE_URL=https://api.example.com
VITE_ENABLE_MOCK=false
VITE_LOG_LEVEL=error

3.2 使用环境变量

// src/utils/config.ts
export const config = {
  appTitle: import.meta.env.VITE_APP_TITLE,
  apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
  enableMock: import.meta.env.VITE_ENABLE_MOCK === 'true',
  logLevel: import.meta.env.VITE_LOG_LEVEL,
  
  // 判断环境
  isDev: import.meta.env.DEV,
  isProd: import.meta.env.PROD,
  mode: import.meta.env.MODE
}

console.log('当前环境:', config.mode)
console.log('API地址:', config.apiBaseUrl)

四、路由配置:让页面"动"起来

4.1 安装路由

npm install vue-router@4

4.2 配置路由

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

// 路由配置
const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@views/Home.vue'),
    meta: {
      title: '首页',
      requiresAuth: false
    }
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('@views/About.vue'),
    meta: {
      title: '关于',
      requiresAuth: false
    }
  },
  {
    path: '/user',
    name: 'User',
    component: () => import('@views/User.vue'),
    meta: {
      title: '个人中心',
      requiresAuth: true
    }
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@views/404.vue')
  }
]

// 创建路由实例
const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

// 全局前置守卫
router.beforeEach((to, from, next) => {
  // 设置页面标题
  document.title = to.meta.title ? `${to.meta.title} - Vite企业级项目` : 'Vite企业级项目'
  
  // 检查是否需要登录
  if (to.meta.requiresAuth) {
    const token = localStorage.getItem('token')
    if (token) {
      next()
    } else {
      next({ path: '/login', query: { redirect: to.fullPath } })
    }
  } else {
    next()
  }
})

export default router

4.3 创建页面组件

<!-- src/views/Home.vue -->
<template>
  <div class="home">
    <h2>🏠 首页</h2>
    <p>欢迎来到首页!</p>
    <button @click="goToAbout">去关于页面</button>
  </div>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router'

const router = useRouter()
const goToAbout = () => {
  router.push('/about')
}
</script>

4.4 在 main.ts 中注册路由

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

const app = createApp(App)

app.use(router) // 注册路由

app.mount('#app')

五、状态管理:Pinia 来了

5.1 为什么不用 Vuex?

// Vuex 的写法
mutations: {
  SET_USER(state, user) {
    state.user = user
  }
},
actions: {
  async fetchUser({ commit }) {
    const user = await api.getUser()
    commit('SET_USER', user)
  }
}

// Pinia 的写法
export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  actions: {
    async fetchUser() {
      this.user = await api.getUser()
    }
  }
})

Pinia 更简洁、更 TypeScript 友好、更模块化!

5.2 安装 Pinia

npm install pinia
npm install pinia-plugin-persistedstate # 持久化插件

5.3 创建 store

// src/store/index.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

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

export default pinia
// src/store/modules/user.ts
import { defineStore } from 'pinia'

interface UserState {
  token: string | null
  userInfo: {
    id?: number
    name?: string
    avatar?: string
    role?: string
  } | null
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    token: localStorage.getItem('token'),
    userInfo: null
  }),
  
  getters: {
    isLoggedIn: (state) => !!state.token,
    userName: (state) => state.userInfo?.name || '游客',
    userRole: (state) => state.userInfo?.role || 'guest'
  },
  
  actions: {
    // 登录
    async login(credentials: { username: string; password: string }) {
      try {
        // 模拟登录请求
        const response = await fetch('/api/login', {
          method: 'POST',
          body: JSON.stringify(credentials)
        })
        const data = await response.json()
        
        this.token = data.token
        this.userInfo = data.userInfo
        
        localStorage.setItem('token', data.token)
        
        return true
      } catch (error) {
        console.error('登录失败:', error)
        return false
      }
    },
    
    // 登出
    logout() {
      this.token = null
      this.userInfo = null
      localStorage.removeItem('token')
    },
    
    // 获取用户信息
    async fetchUserInfo() {
      if (!this.token) return
        
      try {
        const response = await fetch('/api/user/info', {
          headers: {
            Authorization: `Bearer ${this.token}`
          }
        })
        this.userInfo = await response.json()
      } catch (error) {
        console.error('获取用户信息失败:', error)
      }
    }
  },
  
  persist: {
    key: 'user-store',
    storage: localStorage,
    paths: ['token'] // 只持久化 token
  }
})
// src/store/modules/app.ts
import { defineStore } from 'pinia'

interface AppState {
  sidebarCollapsed: boolean
  theme: 'light' | 'dark'
  language: 'zh' | 'en'
}

export const useAppStore = defineStore('app', {
  state: (): AppState => ({
    sidebarCollapsed: false,
    theme: 'light',
    language: 'zh'
  }),
  
  getters: {
    isSidebarCollapsed: (state) => state.sidebarCollapsed,
    currentTheme: (state) => state.theme,
    currentLanguage: (state) => state.language
  },
  
  actions: {
    toggleSidebar() {
      this.sidebarCollapsed = !this.sidebarCollapsed
    },
    
    setTheme(theme: 'light' | 'dark') {
      this.theme = theme
      document.documentElement.setAttribute('data-theme', theme)
    },
    
    setLanguage(language: 'zh' | 'en') {
      this.language = language
    }
  },
  
  persist: true // 持久化整个 store
})

5.4 在组件中使用

<!-- src/views/User.vue -->
<template>
  <div class="user">
    <h2>👤 个人中心</h2>
    
    <div v-if="userStore.isLoggedIn">
      <p>欢迎回来,{{ userStore.userName }}!</p>
      <p>角色:{{ userStore.userRole }}</p>
      <button @click="handleLogout">退出登录</button>
    </div>
    
    <div v-else>
      <p>请先登录</p>
      <button @click="handleLogin">模拟登录</button>
    </div>
    
    <hr>
    
    <h3>应用设置</h3>
    <p>侧边栏状态: {{ appStore.isSidebarCollapsed ? '折叠' : '展开' }}</p>
    <button @click="appStore.toggleSidebar">切换侧边栏</button>
    
    <p>当前主题: {{ appStore.currentTheme }}</p>
    <button @click="appStore.setTheme('dark')">深色模式</button>
    <button @click="appStore.setTheme('light')">浅色模式</button>
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import { useAppStore } from '@/store/modules/app'

const userStore = useUserStore()
const appStore = useAppStore()

const handleLogin = async () => {
  const success = await userStore.login({
    username: 'admin',
    password: '123456'
  })
  
  if (success) {
    alert('登录成功!')
  }
}

const handleLogout = () => {
  userStore.logout()
  alert('已退出登录')
}
</script>

六、UI 组件库集成:按需引入的艺术

6.1 安装 Element Plus

npm install element-plus
npm install -D unplugin-vue-components unplugin-auto-import

6.2 配置自动按需引入

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/types/auto-imports.d.ts',
      eslintrc: {
        enabled: true, // 生成 .eslintrc-auto-import.json
        filepath: './.eslintrc-auto-import.json'
      }
    }),
    Components({
      resolvers: [ElementPlusResolver()],
      dts: 'src/types/components.d.ts',
      dirs: ['src/components'] // 自动注册自己的组件
    })
  ]
})

6.3 自定义主题

// src/styles/element.scss
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'primary': (
      'base': #409eff,
    ),
    'success': (
      'base': #67c23a,
    ),
    'warning': (
      'base': #e6a23c,
    ),
    'danger': (
      'base': #f56c6c,
    ),
    'info': (
      'base': #909399,
    ),
  )
);

// 如果需要,可以导入所有样式
// @use "element-plus/theme-chalk/src/index.scss" as *;
// vite.config.ts 添加 CSS 配置
export default defineConfig({
  // ... 其他配置
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "@/styles/element.scss" as *;`
      }
    }
  }
})

6.4 在组件中使用

<template>
  <div>
    <el-button type="primary" @click="handleClick">
      主要按钮
    </el-button>
    
    <el-table :data="tableData" style="width: 100%">
      <el-table-column prop="date" label="日期" width="180" />
      <el-table-column prop="name" label="姓名" width="180" />
      <el-table-column prop="address" label="地址" />
    </el-table>
    
    <el-pagination
      v-model:current-page="currentPage"
      :page-size="pageSize"
      :total="total"
      layout="prev, pager, next"
    />
  </div>
</template>

<script setup lang="ts">
// 不用手动导入,自动按需引入!
const handleClick = () => {
  ElMessage.success('点击成功!')
}

const tableData = [
  { date: '2024-01-01', name: '张三', address: '北京市' }
]

const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(100)
</script>

七、HTTP 请求封装:让 API 调用更优雅

7.1 封装 axios

npm install axios
// src/utils/request.ts
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { useUserStore } from '@/store/modules/user'
import { ElMessage, ElMessageBox } from 'element-plus'

// 定义响应数据类型
export interface ApiResponse<T = any> {
  code: number
  data: T
  message: string
}

class Request {
  private instance: AxiosInstance
  
  constructor(config: AxiosRequestConfig) {
    this.instance = axios.create(config)
    
    // 请求拦截器
    this.instance.interceptors.request.use(
      (config) => {
        // 添加 token
        const userStore = useUserStore()
        if (userStore.token) {
          config.headers.Authorization = `Bearer ${userStore.token}`
        }
        
        // 开发环境打印请求信息
        if (import.meta.env.DEV) {
          console.log('🚀 请求:', config.method?.toUpperCase(), config.url)
          console.log('参数:', config.params || config.data)
        }
        
        return config
      },
      (error) => {
        return Promise.reject(error)
      }
    )
    
    // 响应拦截器
    this.instance.interceptors.response.use(
      (response: AxiosResponse<ApiResponse>) => {
        const { code, data, message } = response.data
        
        // 根据后端约定的 code 处理业务错误
        if (code !== 200) {
          ElMessage.error(message || '请求失败')
          return Promise.reject(new Error(message))
        }
        
        return data as any
      },
      (error) => {
        // 处理 HTTP 错误
        if (error.response) {
          switch (error.response.status) {
            case 401:
              handleUnauthorized()
              break
            case 403:
              ElMessage.error('没有权限访问')
              break
            case 404:
              ElMessage.error('请求的资源不存在')
              break
            case 500:
              ElMessage.error('服务器错误')
              break
            default:
              ElMessage.error(`请求失败: ${error.response.status}`)
          }
        } else if (error.request) {
          ElMessage.error('网络连接失败,请检查网络')
        } else {
          ElMessage.error('请求配置错误')
        }
        
        return Promise.reject(error)
      }
    )
  }
  
  // 统一请求方法
  public request<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.instance.request(config)
  }
  
  // GET 请求
  public get<T = any>(url: string, params?: any): Promise<T> {
    return this.instance.get(url, { params })
  }
  
  // POST 请求
  public post<T = any>(url: string, data?: any): Promise<T> {
    return this.instance.post(url, data)
  }
  
  // PUT 请求
  public put<T = any>(url: string, data?: any): Promise<T> {
    return this.instance.put(url, data)
  }
  
  // DELETE 请求
  public delete<T = any>(url: string, params?: any): Promise<T> {
    return this.instance.delete(url, { params })
  }
  
  // 上传文件
  public upload<T = any>(url: string, file: File, onProgress?: (progress: number) => void): Promise<T> {
    const formData = new FormData()
    formData.append('file', file)
    
    return this.instance.post(url, formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      onUploadProgress: (progressEvent) => {
        if (onProgress && progressEvent.total) {
          const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
          onProgress(percentCompleted)
        }
      }
    })
  }
  
  // 下载文件
  public download(url: string, filename?: string): Promise<void> {
    return this.instance.get(url, {
      responseType: 'blob'
    }).then(response => {
      const blob = new Blob([response as any])
      const downloadUrl = window.URL.createObjectURL(blob)
      const link = document.createElement('a')
      link.href = downloadUrl
      link.download = filename || 'download'
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)
      window.URL.revokeObjectURL(downloadUrl)
    })
  }
}

// 处理未授权
function handleUnauthorized() {
  ElMessageBox.confirm('登录已过期,请重新登录', '提示', {
    confirmButtonText: '去登录',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    const userStore = useUserStore()
    userStore.logout()
    window.location.href = '/login'
  })
}

// 创建实例
const request = new Request({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json;charset=UTF-8'
  }
})

export default request

7.2 封装 API 模块

// src/api/user.ts
import request from '@/utils/request'

export interface LoginParams {
  username: string
  password: string
}

export interface UserInfo {
  id: number
  name: string
  avatar: string
  role: string
  permissions: string[]
}

export const userApi = {
  // 登录
  login(data: LoginParams) {
    return request.post<{ token: string; userInfo: UserInfo }>('/user/login', data)
  },
  
  // 获取用户信息
  getUserInfo() {
    return request.get<UserInfo>('/user/info')
  },
  
  // 获取用户列表
  getUserList(params: { page: number; limit: number }) {
    return request.get<{ list: UserInfo[]; total: number }>('/user/list', params)
  },
  
  // 更新用户信息
  updateUserInfo(id: number, data: Partial<UserInfo>) {
    return request.put(`/user/${id}`, data)
  },
  
  // 删除用户
  deleteUser(id: number) {
    return request.delete(`/user/${id}`)
  }
}

八、代码规范:让团队代码像一个人写的

8.1 安装 ESLint + Prettier

npm install -D eslint prettier eslint-plugin-vue @vue/eslint-config-typescript
npm install -D @typescript-eslint/eslint-plugin @typescript-eslint/parser
npm install -D eslint-config-prettier eslint-plugin-prettier

8.2 配置 ESLint

// .eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    es2021: true,
    node: true
  },
  extends: [
    'plugin:vue/vue3-recommended',
    '@vue/typescript/recommended',
    'plugin:prettier/recommended'
  ],
  parserOptions: {
    ecmaVersion: 2021,
    parser: '@typescript-eslint/parser',
    sourceType: 'module'
  },
  plugins: ['@typescript-eslint'],
  rules: {
    // 自定义规则
    'vue/multi-word-component-names': 'off',
    '@typescript-eslint/no-explicit-any': 'warn',
    '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
  },
  globals: {
    defineProps: 'readonly',
    defineEmits: 'readonly',
    defineExpose: 'readonly',
    withDefaults: 'readonly'
  }
}

8.3 配置 Prettier

// .prettierrc
{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "none",
  "printWidth": 100,
  "vueIndentScriptAndStyle": true,
  "endOfLine": "auto"
}

8.4 配置 Husky + lint-staged

npm install -D husky lint-staged

# 初始化 husky
npx husky install

# 添加 pre-commit 钩子
npx husky add .husky/pre-commit "npx lint-staged"
// package.json 添加 lint-staged 配置
{
  "lint-staged": {
    "*.{js,ts,vue}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{css,scss,html,json,md}": [
      "prettier --write"
    ]
  }
}

九、打包优化:让项目飞起来

9.1 配置打包分析

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

export default defineConfig({
  plugins: [
    // ... 其他插件
    visualizer({
      filename: 'dist/stats.html',
      open: true,
      gzipSize: true,
      brotliSize: true
    })
  ]
})

9.2 代码分割优化

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 手动分包
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 将 vue、vue-router、pinia 打包在一起
            if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
              return 'vendor-vue'
            }
            
            // 将 element-plus 单独打包
            if (id.includes('element-plus')) {
              return 'vendor-element'
            }
            
            // 其他依赖打包在一起
            return 'vendor-other'
          }
        },
        
        // 自定义 chunk 文件名
        chunkFileNames: 'assets/js/[name]-[hash].js',
        entryFileNames: 'assets/js/[name]-[hash].js',
        assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
      }
    },
    
    // 启用/禁用 CSS 代码分割
    cssCodeSplit: true,
    
    // 设置资源大小限制
    assetsInlineLimit: 4096
  }
})

9.3 图片压缩

npm install -D vite-plugin-imagemin
// vite.config.ts
import viteImagemin from 'vite-plugin-imagemin'

export default defineConfig({
  plugins: [
    // ... 其他插件
    viteImagemin({
      gifsicle: {
        optimizationLevel: 7,
        interlaced: false
      },
      optipng: {
        optimizationLevel: 7
      },
      mozjpeg: {
        quality: 80
      },
      pngquant: {
        quality: [0.8, 0.9],
        speed: 4
      },
      svgo: {
        plugins: [
          {
            name: 'removeViewBox'
          },
          {
            name: 'removeEmptyAttrs',
            active: false
          }
        ]
      }
    })
  ]
})

9.4 打包进度条

npm install -D vite-plugin-progress
// vite.config.ts
import progress from 'vite-plugin-progress'

export default defineConfig({
  plugins: [
    // ... 其他插件
    progress()
  ]
})

9.5 压缩打包结果

npm install -D vite-plugin-compression
// vite.config.ts
import viteCompression from 'vite-plugin-compression'

export default defineConfig({
  plugins: [
    // ... 其他插件
    viteCompression({
      verbose: true,
      disable: false,
      threshold: 10240, // 大于10kb才压缩
      algorithm: 'gzip',
      ext: '.gz'
    })
  ]
})

十、完整的 vite.config.ts

// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { visualizer } from 'rollup-plugin-visualizer'
import viteImagemin from 'vite-plugin-imagemin'
import progress from 'vite-plugin-progress'
import viteCompression from 'vite-plugin-compression'

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
  // 加载环境变量
  const env = loadEnv(mode, process.cwd())
  
  return {
    plugins: [
      vue(),
      
      // 自动导入
      AutoImport({
        resolvers: [ElementPlusResolver()],
        imports: ['vue', 'vue-router', 'pinia'],
        dts: 'src/types/auto-imports.d.ts',
        eslintrc: {
          enabled: true,
          filepath: './.eslintrc-auto-import.json'
        }
      }),
      
      // 自动注册组件
      Components({
        resolvers: [ElementPlusResolver()],
        dts: 'src/types/components.d.ts',
        dirs: ['src/components']
      }),
      
      // 图片压缩
      viteImagemin({
        optipng: { optimizationLevel: 7 },
        mozjpeg: { quality: 80 },
        pngquant: { quality: [0.8, 0.9], speed: 4 },
        svgo: { plugins: [{ name: 'removeViewBox' }] }
      }),
      
      // 打包进度条
      progress(),
      
      // gzip压缩
      viteCompression({
        threshold: 10240,
        algorithm: 'gzip',
        ext: '.gz'
      }),
      
      // 打包分析(只在分析时开启)
      ...(mode === 'analyze' ? [visualizer({
        filename: 'dist/stats.html',
        open: true,
        gzipSize: true,
        brotliSize: true
      })] : [])
    ],
    
    // 路径别名
    resolve: {
      alias: {
        '@': resolve(__dirname, 'src'),
        '@components': resolve(__dirname, 'src/components'),
        '@views': resolve(__dirname, 'src/views'),
        '@utils': resolve(__dirname, 'src/utils'),
        '@styles': resolve(__dirname, 'src/styles')
      }
    },
    
    // CSS 配置
    css: {
      preprocessorOptions: {
        scss: {
          additionalData: `@use "@/styles/element.scss" as *;`
        }
      }
    },
    
    // 开发服务器配置
    server: {
      port: 3000,
      open: true,
      cors: true,
      proxy: {
        '/api': {
          target: env.VITE_API_BASE_URL,
          changeOrigin: true,
          rewrite: (path) => path.replace(/^/api/, '')
        }
      }
    },
    
    // 构建配置
    build: {
      target: 'es2015',
      outDir: 'dist',
      assetsDir: 'assets',
      assetsInlineLimit: 4096,
      sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
      reportCompressedSize: false,
      chunkSizeWarningLimit: 500,
      
      rollupOptions: {
        output: {
          manualChunks(id) {
            if (id.includes('node_modules')) {
              if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
                return 'vendor-vue'
              }
              if (id.includes('element-plus')) {
                return 'vendor-element'
              }
              return 'vendor-other'
            }
          },
          chunkFileNames: 'assets/js/[name]-[hash].js',
          entryFileNames: 'assets/js/[name]-[hash].js',
          assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
        }
      }
    }
  }
})

十一、package.json 完整配置

{
  "name": "vite-enterprise-demo",
  "version": "1.0.0",
  "description": "Vite企业级项目实战",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "build:analyze": "vue-tsc && vite build --mode analyze",
    "preview": "vite preview",
    "type-check": "vue-tsc --noEmit",
    "lint": "eslint . --ext .vue,.js,.ts --fix",
    "format": "prettier --write 'src/**/*.{vue,js,ts,css,scss}'",
    "prepare": "husky install"
  },
  "dependencies": {
    "axios": "^1.6.0",
    "element-plus": "^2.4.0",
    "pinia": "^2.1.0",
    "pinia-plugin-persistedstate": "^3.2.0",
    "vue": "^3.3.0",
    "vue-router": "^4.2.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "@typescript-eslint/eslint-plugin": "^6.0.0",
    "@typescript-eslint/parser": "^6.0.0",
    "@vitejs/plugin-vue": "^4.0.0",
    "@vue/eslint-config-typescript": "^12.0.0",
    "eslint": "^8.0.0",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-prettier": "^5.0.0",
    "eslint-plugin-vue": "^9.0.0",
    "husky": "^8.0.0",
    "lint-staged": "^15.0.0",
    "prettier": "^3.0.0",
    "rollup-plugin-visualizer": "^5.9.0",
    "sass": "^1.69.0",
    "typescript": "^5.0.0",
    "unplugin-auto-import": "^0.16.0",
    "unplugin-vue-components": "^0.25.0",
    "vite": "^4.0.0",
    "vite-plugin-compression": "^0.5.0",
    "vite-plugin-imagemin": "^0.6.0",
    "vite-plugin-progress": "^0.0.7",
    "vue-tsc": "^1.8.0"
  }
}

十二、总结:从“搭环境”到“搭项目”

回顾一下,我们做了什么:

  1. 项目初始化:从空文件夹开始,手动搭建了项目结构
  2. TypeScript配置:告别 any,拥抱类型安全
  3. 环境变量:一套代码,多套环境
  4. 路由配置:让页面动起来
  5. Pinia状态管理:比 Vuex 更香的选择
  6. Element Plus 按需引入:告别全量引入的臃肿
  7. Axios封装:统一的请求处理
  8. 代码规范:ESLint + Prettier + Husky
  9. 打包优化:代码分割、图片压缩、gzip

现在,你拥有的是一个真正的企业级项目模板,而不仅仅是一个“能跑的项目”。

为什么这很重要?

// 面试官问:你们的项目是怎么配置的?
// 初级回答:用的 create-vue 脚手架
// 中级回答:配置了路由、状态管理、按需引入
// 高级回答:从零搭建了完整的工程化体系,包括代码规范、打包优化、环境管理等

当你能够从零搭建一个企业级项目,你就掌握了前端工程化的核心能力。这不仅是一份工作,更是一种将想法转化为产品的能力。

下一步做什么?

  1. 添加单元测试:Vitest + Vue Test Utils
  2. 配置 CI/CD:GitHub Actions 自动部署
  3. 添加 Mock 服务:开发环境模拟数据
  4. 性能监控:集成 Sentry 等错误监控
  5. 文档生成:使用 VitePress 生成组件文档

记住:好的工程化不是一蹴而就的,而是在实践中不断完善的。现在,带着这个模板去创建你的下一个项目吧!🚀

虚拟列表完全指南:从原理到实战,轻松渲染10万条数据

作者 wuhen_n
2026年3月14日 07:23

前言

想象一下这个场景:我们正在开发一个聊天应用,需要展示最近一年的聊天记录,总共有10万条消息。如果用传统方式渲染,页面会直接卡死,用户直接口吐芬芳了。

再想象另一个场景:我们在做一个数据后台,需要在表格中展示5万条日志。如果一次性渲染所有数据,内存占用轻松超过 500MB,用户的电脑风扇会疯狂嘶吼。

这就是虚拟列表要解决的问题:让海量数据的渲染变得像渲染几十条数据一样流畅。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,带领我们一步步掌握虚拟列表的核心技术。

为什么需要虚拟列表?

大量 DOM 元素导致的渲染性能问题

我们先来看一段最普通的代码:

<template>
  <div class="list">
    <div v-for="item in 100000" :key="item" class="list-item">
      第 {{ item }} 条数据
    </div>
  </div>
</template>

猜猜看,这段代码会有什么后果? 实际测试结果

  • 渲染时间:Chrome 需要 3-5 秒才能完成渲染
  • 内存占用:100,000 个 DOM 节点占用约 300MB 内存
  • 滚动卡顿:每秒需要处理大量重绘和回流,像幻灯片一样卡
  • 交互延迟:点击、选中等操作有明显延迟

为什么会出现这种情况?让我们用一个生活中的小示例来解释。

用生活化的比喻理解问题

假如我们在一个巨大的图书馆工作,每天需要整理10万本书:

  • 传统渲染方式:把10万本书全部搬到桌子上,想用哪本拿哪本

    • 桌子被堆得满满的
    • 找一本书要翻半天
    • 挪动一下都费劲
  • 虚拟列表方式:只把当前需要用到的几本书放在桌上:

    • 桌子永远只有几本书
    • 想看其他书时,把新书拿上来,旧书放回去
    • 永远轻松自如

DOM元素为什么这么"重"?

每个DOM元素都不是简单的“标签”,而是一个庞大的 JavaScript 对象:

// 一个简单的 div 元素包含的属性(简化版)
{
  tagName: 'DIV',
  id: '',
  className: '',
  style: { ... },      // 几十个样式属性
  attributes: { ... },  // 属性集合
  children: [],         // 子节点
  parentNode: ...,      // 父节点引用
  offsetHeight: 0,      // 位置信息
  offsetWidth: 0,
  offsetTop: 0,
  offsetLeft: 0,
  // ... 还有几百个其他属性
}

内存占用计算

一个div ≈ 4-8KB
10万个div ≈ 400-800MB
Vue组件实例 ≈ 每个额外占用2-3KB
总计 ≈ 700MB-1.1GB

这就是为什么传统渲染方式会卡死的根本原因。

虚拟列表的核心原理

核心思想:只渲染看得见的元素

虚拟列表的核心思想其实特别简单:用户能看到多少,就渲染多少

可视区域高度: 400px
每个列表项高度: 50px
可视区域能容纳: 8个列表项

数据总量: 100,000条
实际渲染: 8条 + 少量缓冲 = 12条
节省了: 99.988%的DOM节点

图解虚拟列表原理

┌─────────────────────────┐
│   滚动容器 (height:400px)│
│  ┌─────────────────────┐│
│  │   不可见区域(顶部)  ││ ← 用padding-top撑开
│  │   (1000px 空白)     ││
│  ├─────────────────────┤│
│  │   ┌─────────────┐  ││
│  │   │  可视区域    │  ││ ← 只渲染这8条
│  │   │  Item 100   │  ││
│  │   │  Item 101   │  ││
│  │   │  Item 102   │  ││
│  │   │  Item 103   │  ││
│  │   │  Item 104   │  ││
│  │   │  Item 105   │  ││
│  │   │  Item 106   │  ││
│  │   │  Item 107   │  ││
│  │   └─────────────┘  ││
│  ├─────────────────────┤│
│  │   不可见区域(底部) ││ ← 用padding-bottom撑开
│  │   (9000px 空白)     ││
│  └─────────────────────┘│
└─────────────────────────┘

三个关键技术点

1. 计算可视区域

// 已知条件
容器高度 = 400px
列表项高度 = 50px

// 计算可视区域能显示多少个
可视数量 = 容器高度 / 列表项高度 = 8// 根据滚动位置计算应该显示哪些
开始索引 = 滚动高度 / 列表项高度
结束索引 = 开始索引 + 可视数量

2. 撑起滚动条

为了让滚动条显示正确的总高度,我们通常需要创建一个占位元素

<div class="container">
  <!-- 占位元素:只有高度,没有内容,用于撑开滚动条 -->
  <div :style="{ height: totalHeight + 'px' }"></div>
  
  <!-- 实际内容:通过绝对定位或transform移动位置 -->
  <div :style="{ transform: `translateY(${offsetY}px)` }">
    <div v-for="item in visibleItems">...</div>
  </div>
</div>

3. 滚动时更新内容

function onScroll(event) {
  // 获取滚动位置
  const scrollTop = event.target.scrollTop
  
  // 计算新的开始索引
  const startIndex = Math.floor(scrollTop / itemHeight)
  
  // 更新可视区域的数据
  visibleItems.value = data.slice(startIndex, startIndex + visibleCount)
  
  // 计算偏移量,让内容移动到正确位置
  offsetY.value = startIndex * itemHeight
}

从零实现固定高度虚拟列表

最简单的实现

让我们从一个最基础的版本开始,帮助我们理解虚拟列表的核心逻辑:

<template>
  <!-- 滚动容器 -->
  <div 
    class="virtual-list" 
    @scroll="onScroll"
    :style="{ height: containerHeight + 'px' }"
    ref="containerRef"
  >
    <!-- 占位元素:撑开滚动条 -->
    <div 
      class="placeholder" 
      :style="{ height: totalHeight + 'px' }"
    ></div>
    
    <!-- 实际内容区域 -->
    <div 
      class="content" 
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div 
        v-for="item in visibleData" 
        :key="item.id"
        class="list-item"
      >
        {{ item.name }}
      </div>
    </div>
  </div>
</template>

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

// 接收父组件传过来的数据
const props = defineProps({
  data: {
    type: Array,
    required: true
  },
  itemHeight: {
    type: Number,
    default: 50
  },
  containerHeight: {
    type: Number,
    default: 400
  }
})

// 当前滚动位置
const scrollTop = ref(0)

// 计算可视区域能显示多少个
const visibleCount = computed(() => 
  Math.ceil(props.containerHeight / props.itemHeight)
)

// 计算开始索引
const startIndex = computed(() => 
  Math.floor(scrollTop.value / props.itemHeight)
)

// 计算结束索引
const endIndex = computed(() => 
  Math.min(startIndex.value + visibleCount.value, props.data.length)
)

// 可视区域的数据
const visibleData = computed(() => 
  props.data.slice(startIndex.value, endIndex.value)
)

// 内容总高度
const totalHeight = computed(() => 
  props.data.length * props.itemHeight
)

// 内容偏移量
const offsetY = computed(() => 
  startIndex.value * props.itemHeight
)

// 滚动处理函数
function onScroll(event) {
  scrollTop.value = event.target.scrollTop
}
</script>

<style scoped>
.virtual-list {
  position: relative;
  overflow-y: auto;
  border: 1px solid #e8e8e8;
}

.placeholder {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: -1;
}

.content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.list-item {
  height: v-bind(itemHeight + 'px');
  line-height: v-bind(itemHeight + 'px');
  padding: 0 16px;
  border-bottom: 1px solid #f0f0f0;
  box-sizing: border-box;
}
</style>

组件使用示例

<template>
  <VirtualList 
    :data="largeData" 
    :item-height="50" 
    :container-height="400"
  />
</template>

<script setup>
import VirtualList from './components/VirtualList.vue'

// 生成10万条测试数据
const largeData = ref(
  Array.from({ length: 100000 }, (_, i) => ({
    id: i,
    name: `用户 ${i}`,
    email: `user${i}@example.com`
  }))
)
</script>

存在的问题和改进

上面的基础版本虽然能用,但有几个问题:

  • 快速滚动时会出现白屏
  • 没有缓冲区域,滚动体验不好
  • 性能还可以进一步优化

解决方案:添加缓冲区

// 添加 overscan 参数,在可视区域上下额外渲染几个
const props = defineProps({
  // ... 其他参数
  overscan: {
    type: Number,
    default: 3 // 上下各多渲染3个
  }
})

const startIndex = computed(() => {
  let index = Math.floor(scrollTop.value / props.itemHeight)
  // 减去上缓冲
  index = Math.max(0, index - props.overscan)
  return index
})

const endIndex = computed(() => {
  let index = startIndex.value + visibleCount.value + props.overscan * 2
  index = Math.min(index, props.data.length)
  return index
})

封装成可复用的组合式函数

为了更好的复用性,我们可以把逻辑提取到组合式函数中:

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

export function useVirtualList(data, options) {
  const {
    itemHeight,
    containerHeight,
    overscan = 3
  } = options

  const scrollTop = ref(0)
  
  // 可视区域能显示的最大项目数
  const visibleCount = computed(() => 
    Math.ceil(containerHeight / itemHeight)
  )
  
  // 起始索引
  const startIndex = computed(() => {
    let index = Math.floor(scrollTop.value / itemHeight)
    index = Math.max(0, index - overscan)
    return index
  })
  
  // 结束索引
  const endIndex = computed(() => {
    let index = startIndex.value + visibleCount.value + overscan * 2
    index = Math.min(index, data.length)
    return index
  })
  
  // 可视区域的数据
  const visibleData = computed(() => 
    data.slice(startIndex.value, endIndex.value)
  )
  
  // 内容总高度
  const totalHeight = computed(() => data.length * itemHeight)
  
  // 内容偏移量
  const offsetY = computed(() => startIndex.value * itemHeight)
  
  // 滚动处理函数
  const onScroll = (event) => {
    scrollTop.value = event.target.scrollTop
  }
  
  // 滚动到指定索引
  const scrollTo = (index) => {
    const targetScroll = index * itemHeight
    scrollTop.value = targetScroll
    return targetScroll
  }
  
  return {
    visibleData,
    totalHeight,
    offsetY,
    onScroll,
    scrollTo,
    startIndex,
    endIndex
  }
}

进阶:动态高度的虚拟列表

为什么要处理动态高度?

在实际应用中,列表项的高度往往是动态的,我们无法提前得知它到底会占用多少高度:

<!-- 每条消息的高度都不一样 -->
<div class="message">
  <div class="header">张三 14:30</div>
  <div class="content">
    这是一条很短的消息
  </div>
</div>

<div class="message">
  <div class="header">李四 14:31</div>
  <div class="content">
    这是一条很长的消息,可能会换行,可能会换很多行,
    所以这个元素的高度会比上一条高很多...
  </div>
</div>

核心挑战

动态高度的主要挑战是:在渲染之前,我们不知道每个元素的具体高度,这就带来了两个问题:

  • 无法准确计算滚动条的总高度
  • 无法精确定位滚动到某个元素

解决方案:预估 + 测量 + 缓存

1. 预估一个默认高度

// 先给每个元素一个预估高度
const itemSizes = ref(
  data.map(() => ({
    height: 40,        // 预估高度
    measured: false    // 是否已测量
  }))
)

2. 渲染后测量真实高度

// 在组件渲染后测量实际高度
function measureItem(index, element) {
  if (element && !itemSizes.value[index].measured) {
    const height = element.offsetHeight
    itemSizes.value[index].height = height
    itemSizes.value[index].measured = true
  }
}

3. 缓存测量结果,并更新总高度

// 计算累积高度(用于快速定位)
const cumulativeHeights = computed(() => {
  const heights = [0]
  let total = 0
  
  for (let i = 0; i < itemSizes.value.length; i++) {
    total += itemSizes.value[i].height
    heights.push(total)
  }
  
  return heights
})

// 总高度
const totalHeight = computed(() => 
  cumulativeHeights.value[data.length] || 0
)

完整实现

<!-- DynamicVirtualList.vue -->
<template>
  <div 
    class="virtual-list"
    :style="{ height: containerHeight + 'px' }"
    @scroll="onScroll"
    ref="containerRef"
  >
    <!-- 占位元素:撑起滚动条 -->
    <div 
      class="phantom"
      :style="{ height: totalHeight + 'px' }"
    ></div>
    
    <!-- 可见内容 -->
    <div 
      class="content"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="(item, idx) in visibleData"
        :key="item.id"
        :data-index="startIndex + idx"
        ref="itemRefs"
        class="list-item"
      >
        <slot 
          name="item" 
          :item="item" 
          :index="startIndex + idx"
        >
          <div class="default-item">
            {{ item.name || item }}
          </div>
        </slot>
      </div>
    </div>
  </div>
</template>

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

const props = defineProps({
  data: {
    type: Array,
    required: true
  },
  estimatedItemHeight: {
    type: Number,
    default: 40
  },
  containerHeight: {
    type: Number,
    required: true
  },
  overscan: {
    type: Number,
    default: 3
  }
})

// 存储每个项的高度
const itemSizes = ref(
  props.data.map(() => ({
    height: props.estimatedItemHeight,
    measured: false
  }))
)

// 当前滚动位置
const scrollTop = ref(0)

// 容器引用
const containerRef = ref()
const itemRefs = ref([])

// 计算累积高度(用于快速定位)
const cumulativeHeights = computed(() => {
  const heights = [0]
  let total = 0
  
  for (let i = 0; i < itemSizes.value.length; i++) {
    total += itemSizes.value[i].height
    heights.push(total)
  }
  
  return heights
})

// 总高度
const totalHeight = computed(() => 
  cumulativeHeights.value[props.data.length] || 0
)

// 二分查找:根据滚动位置找起始索引
function findStartIndex(scrollTop) {
  const heights = cumulativeHeights.value
  let left = 0
  let right = heights.length - 1
  
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    const midValue = heights[mid]
    
    if (midValue === scrollTop) {
      return mid
    } else if (midValue < scrollTop) {
      left = mid + 1
    } else {
      right = mid - 1
    }
  }
  
  return Math.max(0, right)
}

// 计算可见区域的起止索引
const startIndex = computed(() => {
  return Math.max(0, findStartIndex(scrollTop.value) - props.overscan)
})

const endIndex = computed(() => {
  let end = startIndex.value
  let currentHeight = cumulativeHeights.value[startIndex.value]
  const targetHeight = scrollTop.value + props.containerHeight
  
  while (
    end < props.data.length && 
    currentHeight < targetHeight + props.estimatedItemHeight * props.overscan
  ) {
    end++
    currentHeight = cumulativeHeights.value[end]
  }
  
  return Math.min(end + props.overscan, props.data.length)
})

// 可见区域的数据
const visibleData = computed(() => 
  props.data.slice(startIndex.value, endIndex.value)
)

// 内容偏移量
const offsetY = computed(() => 
  cumulativeHeights.value[startIndex.value] || 0
)

// 测量元素高度
function measureItems() {
  nextTick(() => {
    itemRefs.value.forEach((el, idx) => {
      if (!el) return
      
      const globalIndex = startIndex.value + idx
      const height = el.offsetHeight
      
      // 如果高度变化了,更新缓存
      if (height > 0 && itemSizes.value[globalIndex].height !== height) {
        itemSizes.value[globalIndex].height = height
        itemSizes.value[globalIndex].measured = true
      }
    })
  })
}

// 滚动处理
function onScroll(event) {
  scrollTop.value = event.target.scrollTop
}

// 当可见数据变化时,重新测量
watch(visibleData, measureItems, { immediate: true })

// 滚动到指定项
function scrollTo(index) {
  if (index < 0 || index >= props.data.length) return
  
  const targetScroll = cumulativeHeights.value[index]
  if (containerRef.value) {
    containerRef.value.scrollTop = targetScroll
    scrollTop.value = targetScroll
  }
}

defineExpose({
  scrollTo
})
</script>

<style scoped>
.virtual-list {
  position: relative;
  overflow-y: auto;
}

.phantom {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: -1;
}

.content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.list-item {
  box-sizing: border-box;
}
</style>

组件使用示例

<template>
  <div class="demo">
    <h3>动态高度虚拟列表</h3>
    <p>可见区域: {{ startIndex }} - {{ endIndex }}</p>
    
    <DynamicVirtualList
      :data="messages"
      :container-height="500"
      :estimated-item-height="60"
      ref="listRef"
    >
      <template #item="{ item, index }">
        <div class="message" :class="{ mine: item.isMine }">
          <div class="header">
            <span class="name">{{ item.name }}</span>
            <span class="time">{{ item.time }}</span>
          </div>
          <div class="content">{{ item.content }}</div>
          <div v-if="item.image" class="image">
            <img :src="item.image" @load="listRef?.measureItems()" />
          </div>
        </div>
      </template>
    </DynamicVirtualList>
    
    <button @click="scrollTo(500)">滚动到第500条</button>
  </div>
</template>

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

// 生成模拟聊天数据
const messages = ref(
  Array.from({ length: 5000 }, (_, i) => {
    const hasImage = i % 10 === 0
    const isLong = i % 5 === 0
    
    return {
      id: i,
      name: i % 2 === 0 ? '张三' : '李四',
      time: new Date(Date.now() - i * 60000).toLocaleTimeString(),
      content: isLong 
        ? '这是一条很长的消息,用来测试动态高度效果。'.repeat(5 + Math.floor(Math.random() * 10))
        : '这是一条普通消息',
      isMine: i % 3 === 0,
      image: hasImage ? `https://picsum.photos/200/150?random=${i}` : null
    }
  })
)

const listRef = ref()
const startIndex = ref(0)
const endIndex = ref(0)

function scrollTo(index) {
  listRef.value?.scrollTo(index)
}
</script>

<style>
.message {
  padding: 12px 16px;
  border-bottom: 1px solid #f0f0f0;
}

.message.mine {
  background-color: #e6f7ff;
}

.header {
  margin-bottom: 8px;
}

.name {
  font-weight: 600;
  margin-right: 12px;
}

.time {
  color: #999;
  font-size: 12px;
}

.content {
  line-height: 1.6;
  color: #333;
}

.image {
  margin-top: 8px;
}

.image img {
  max-width: 200px;
  border-radius: 4px;
}
</style>

性能优化技巧

使用 requestAnimationFrame 优化滚动

滚动事件会触发频繁计算更新,可以使用 requestAnimationFrame 节流:

let ticking = false

function onScroll(event) {
  if (!ticking) {
    requestAnimationFrame(() => {
      scrollTop.value = event.target.scrollTop
      ticking = false
    })
    ticking = true
  }
}

使用 v-memo 缓存列表项

对于高度复杂的列表项,可以使用 v-memo 缓存渲染结果,避免不必要的更新:

<template>
  <div
    v-for="item in visibleData"
    :key="item.id"
    v-memo="[item.id, item.version, item.likes]"
    class="list-item"
  >
    <ComplexItem :data="item" />
  </div>
</template>

<!-- v-memo 的作用:只有当依赖的值变化时才重新渲染 -->
<!-- 避免因为父组件更新导致的无关渲染 -->

使用 shallowRef 处理大型数据

对于大型数据,如果直接使用 ref 定义,每个属性都变成响应式,开销大。这时我们可以使用 shallowRef 避免深层响应式:

import { shallowRef } from 'vue'

// shallowRef:只有数组引用变化时才会触发更新
const data = shallowRef(generateLargeArray())

// 更新时替换整个数组
function updateData(newArray: any[]) {
  data.value = newArray
}

// 修改单个项不会触发响应式
function updateItem(index: number, newValue: any) {
  // 更新时,需要创建新数组
  const newData = [...data.value]
  newData[index] = newValue
  data.value = newData
}

使用 Intersection Observer 优化图片加载

// 使用 Intersection Observer 实现图片懒加载
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target
        const src = img.dataset.src
        if (src) {
          img.src = src
          img.removeAttribute('data-src')
          observer.unobserve(img)
        }
      }
    })
  },
  {
    rootMargin: '100px' // 提前100px加载
  }
)

// 在列表项渲染后观察图片
watch(visibleData, () => {
  nextTick(() => {
    document.querySelectorAll('img[data-src]').forEach(img => {
      observer.observe(img)
    })
  })
})

性能优化清单

优化点 方法 效果
滚动事件 requestAnimationFrame 减少计算次数
列表项更新 v-memo 避免无关渲染
大型数据 shallowRef 减少响应式开销
图片加载 Intersection Observer 按需加载
高度测量 ResizeObserver 监听高度变化
缓存策略 LRU缓存 限制缓存大小

第三方库推荐

vue-virtual-scroller

安装

npm install vue-virtual-scroller@next
# 或者:
yarn install vue-virtual-scroller@next

使用

<template>
  <RecycleScroller
    class="scroller"
    :items="list"
    :item-size="50"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="item">
      {{ item.name }}
    </div>
  </RecycleScroller>
</template>

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

const list = ref(Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `Item ${i}`
})))
</script>

优点

  • 功能完善,支持动态高度
  • 性能优秀,经过大量项目验证
  • 提供网格布局支持
  • 有活跃的社区维护

缺点

  • 需要额外引入CSS
  • 包体积较大(约20KB)
  • 定制复杂样式可能受限

与手写对比

场景 推荐方案 原因
学习目的 手写 深入理解原理
简单固定高度 手写 实现简单,无依赖
生产环境复杂需求 第三方库 稳定可靠,功能完善
特殊定制需求 手写 完全可控
团队协作项目 第三方库 减少维护成本

常见问题与解决方案

问题1:快速滚动出现白屏

当页面滚动太快时,新的内容来不及渲染,出现白屏。

解决方案:缓冲区 + 骨架屏占位

<script setup>
// 增加缓冲
const props = defineProps({
  overscan: {
    type: Number,
    default: 5 // 增加到5个
  }
})
</script>

<!-- 显示骨架屏占位 -->
<template>
  <div v-if="loading" class="skeleton">
    <div v-for="n in 5" class="skeleton-item"></div>
  </div>
</template>

问题2:高度测量不准确

由于不同规格的图片加载、字体渲染等原因,导致高度发生变化,高度测量不准。

解决方案:使用 ResizeObserver 监听高度变化

import { useResizeObserver } from '@vueuse/core'

useResizeObserver(itemRefs, (entries) => {
  entries.forEach(entry => {
    const index = entry.target.dataset.index
    if (index) {
      measureItem(Number(index), entry.contentRect.height)
    }
  })
})

问题3:滚动位置跳动

当上方元素的高度发生变化时,滚动位置会跳动。

解决方案:使用 scroll-save 保持滚动位置

// 保存当前视口顶部的元素
function saveScrollPosition() {
  const container = containerRef.value
  if (!container) return
  
  const firstVisibleIndex = findStartIndex(container.scrollTop)
  const firstVisibleElement = document.querySelector(`[data-index="${firstVisibleIndex}"]`)
  
  if (firstVisibleElement) {
    const offset = firstVisibleElement.getBoundingClientRect().top
    savedPosition.value = { index: firstVisibleIndex, offset }
  }
}

问题4:内存泄漏

当组件销毁时没有及时清理观察者和定时器,导致内存泄漏。

解决方案:及时清理

import { onUnmounted } from 'vue'

// 保存所有需要清理的资源
const observers = []
const timers = []

// 组件销毁时清理
onUnmounted(() => {
  observers.forEach(observer => observer.disconnect())
  timers.forEach(timer => clearTimeout(timer))
})

虚拟列表的适用场景

何时应该使用虚拟列表?

场景 数据量 是否使用 原因
聊天记录 1000+ 无限滚动,DOM 爆炸
商品列表 1000+ 首屏加载慢
后台表格 10000+ 性能卡顿
下拉菜单 <100 简单列表,没必要
评论列表 <500 ⚠️ 酌情使用,看复杂度
卡片列表 <200 正常渲染即可

性能对比

方案 DOM 节点数 内存占用 滚动帧率 实现复杂度
传统渲染 100,000 500-800MB 5-10fps
固定高度虚拟列表 20-30 5-10MB 60fps
动态高度虚拟列表 20-30 5-10MB 55-60fps
第三方库 20-30 5-10MB 60fps

最佳实践清单

  • 预估高度:动态高度列表需要合理的预估高度
  • 缓冲区域:上下各保留 2-5 个缓冲项
  • 测量机制:动态高度需要精确测量
  • 滚动优化:使用 ref 节流
  • 键值管理:使用稳定的唯一键
  • 内存释放:及时清理观察者和定时器

性能优化清单

  • 使用 requestAnimationFrame 优化滚动事件
  • 添加 overscan 缓冲区域
  • 使用 v-memo 缓存复杂列表项
  • 大型数据用 shallowRef 存储
  • 图片使用懒加载
  • 监听高度变化并及时更新
  • 组件销毁时清理资源

用户体验清单

  • 快速滚动时显示骨架屏
  • 滚动到底部自动加载更多
  • 有新消息,自动滚动到底部
  • 支持点击滚动到指定项
  • 支持滚动位置(返回时恢复)

最终的代码模板

// 一个完整的虚拟列表组合式函数模板
export function useVirtualList<T>(
  data: Ref<T[]>,
  options: {
    itemHeight: number
    containerHeight: number
    dynamicHeight?: boolean
    overscan?: number
  }
) {
  // 状态管理
  const scrollTop = ref(0)
  const startIndex = ref(0)
  
  // 计算可见数据
  const visibleData = computed(() => {
    // 计算逻辑
  })
  
  // 滚动处理(节流)
  const onScroll = useThrottle((e: Event) => {
    // 更新 scrollTop
  }, 16)
  
  // 动态高度测量
  const measureItem = (index: number, height: number) => {
    // 更新缓存
  }
  
  // 滚动到指定项
  const scrollTo = (index: number) => {
    // 计算目标位置并滚动
  }
  
  return {
    visibleData,
    totalHeight: computed(() => data.value.length * options.itemHeight),
    offsetY: computed(() => startIndex.value * options.itemHeight),
    onScroll,
    measureItem,
    scrollTo
  }
}

结语

虚拟列表的核心思想很简单:用计算换渲染,用内存换时间。通过只渲染可见区域,我们可以在处理海量数据时保持流畅的体验。无论是固定高度还是动态高度,掌握其原理后,我们就能根据实际需求选择最合适的方案。

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

Vue Router 进阶:路由懒加载、导航守卫与元信息的高效运用

作者 wuhen_n
2026年3月14日 07:21

前言

如果你问我:在一个Vue应用中,最重要的部分是什么?我的答案是:路由系统。路由就像是应用的骨架,它决定了:

  • 用户如何从一个页面导航到另一个页面
  • 哪些用户可以访问哪些页面
  • 页面的加载速度有多快
  • 用户体验是否流畅

但很多开发者对 Vue Router 的理解,仅仅停留在配置路径和组件的层面。这就像知道如何使用电灯开关,却不懂电路设计一样。本文将从最基础的概念讲起,一步步深入Vue Router 4的核心功能,最终帮你构建一个健壮、高效、易维护的企业级路由系统。无论你是刚接触Vue3的新手,还是经验丰富的老手,都能在这里找到有价值的内容。

为什么需要深入理解路由?

从一个真实场景开始

假设我们在开发一个后台管理系统,需要实现以下功能:

  • 未登录用户只能访问登录页
  • 不同角色的用户看到不同的菜单
  • 页面切换时显示进度条
  • 离开页面时如果有未保存数据要提示
  • 某些页面需要预加载数据
  • 页面标题要动态更新

如果只是简单地配置路由,代码很快就会变得混乱不堪:

  • 每个组件都要自己检查权限
  • 每个组件都要自己更新标题
  • 每个组件都要自己处理数据预加载

这就是为什么我们需要深入理解路由,路由系统可以统一处理这些横切关注点,让代码更加清晰、可维护。

Vue Router 4 的核心设计哲学

从 Vue Router 3 到 4 的演进

Vue Router 4 专为 Vue3 设计,带来了几个重要的变化:

特性 Vue Router 3 Vue Router 4 优势
API风格 Options API Composition API优先 更好的逻辑复用
TypeScript 有限支持 原生支持 类型安全
动态路由 addRoutes addRoute(更灵活) 精细控制

路由懒加载:让首屏飞起来

为什么要懒加载?

我们先看一个反例,如果没有路由懒加载,那么所有路由组件都会直接打包成一个 JS 文件,导致首屏加载慢,白屏时间长:

// ❌ 错误写法:所有组件一起打包
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import User from '@/views/User.vue'
import Dashboard from '@/views/Dashboard.vue'
// ... 假设有50个页面

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
  // ...
]

上述代码,问题出在哪?

  • 所有页面代码都打包成一个巨大的JS文件
  • 用户访问首页,却要下载所有页面的代码
  • 首屏加载时间随着项目变大而线性增长

正确的懒加载方式

// ✅ 正确写法:使用动态导入
const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('@/views/About.vue')
  },
  {
    path: '/user/:id',
    name: 'User',
    component: () => import('@/views/User.vue')
  }
]

动态导入的原理

  1. () => import('@/views/Home.vue') 看起来像函数调用,但本质是一个操作符(类似 typeof),它返回一个 Promise
  2. 构建时的处理(Webpack/Vite):
    1. 解析这个动态导入语句
    2. 为这个模块创建一个独立的 chunk(代码块)
    3. 生成对应的 chunk 文件名(如:Home.[hash].js
    4. 记录这个映射关系
  3. 在路由匹配时动态加载这个 chunk 文件
  4. 加载完成后渲染组件

路由懒加载的最佳实践

策略一:按路由层级拆分

// 每个路由单独打包,适合页面之间差异大的场景
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/layouts/DashboardLayout.vue'),
    children: [
      {
        path: 'overview',
        component: () => import('@/views/dashboard/Overview.vue')
      },
      {
        path: 'analytics',
        component: () => import('@/views/dashboard/Analytics.vue')
      }
    ]
  }
]
打包结果
dashboard.js      - 包含布局组件
overview.js       - 包含概览页面
analytics.js      - 包含分析页面

策略二:按功能模块拆分

// 同一个模块的路由打包在一起,适合关联性强的页面
const UserModule = () => import(/* webpackChunkName: "user" */ '@/views/user')

const routes = [
  {
    path: '/user',
    component: UserModule,
    children: [
      { 
        path: 'profile', 
        component: () => import('@/views/user/Profile.vue') 
      },
      { 
        path: 'settings', 
        component: () => import('@/views/user/Settings.vue') 
      }
    ]
  }
]
打包结果
user.js  - 包含用户模块的所有页面(适合模块内页面关联性强的场景)

策略三:路由预加载(Preloading)

// 用户鼠标悬停在链接上时预加载
const handleMouseEnter = () => {
  // 预加载用户页面
  import('@/views/User.vue')
}

// 或者在路由元信息中配置预加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: { 
      preload: true  // 表示需要预加载
    }
  }
]

// 全局预加载策略
router.beforeEach(async (to, from) => {
  // 预加载可能访问的下一个页面
  const likelyNextRoutes = ['/products', '/about']
  likelyNextRoutes.forEach(routePath => {
    // 找到对应的路由配置并预加载
    const route = router.resolve(routePath)
    if (route.matched.length) {
      // 触发组件加载
      route.matched.forEach(record => {
        if (record.components?.default) {
          // 预加载组件
          const component = record.components.default
          if (typeof component === 'function') {
            component()  // 调用加载函数
          }
        }
      })
    }
  })
})

懒加载的性能收益分析

指标 优化前 优化后 提升
首屏 JS 体积 2.3 MB 450 KB 80%
FCP 2.8 s 1.2 s 57%
LCP 3.5 s 1.6 s 54%
TTI 4.2 s 2.1 s 50%

导航守卫:路由的守门人

什么是导航守卫?

想象一下,当我们需要进入一个安检的大楼:

  • 门口保安:检查身份证(全局前置守卫)
  • 楼层管理员:检查是否有权限进入该楼层(路由独享守卫)
  • 办公室门禁:检查是否是该办公室的员工(组件内守卫)

导航守卫就是路由系统的安检系统

导航守卫的执行流程全景图

用户点击链接
    ↓
触发导航
    ↓
【离开当前页面的组件守卫】← 如果有未保存数据,可以阻止离开
    ↓
【全局前置守卫】← 检查登录状态、权限等
    ↓
【路由独享守卫】← 特定路由的额外检查
    ↓
【组件内守卫(进入前)】← 可以在这里预加载数据
    ↓
解析异步组件(如果还没加载)
    ↓
【全局解析守卫】← 所有守卫都通过后,导航确认前
    ↓
导航被确认
    ↓
更新DOM
    ↓
【全局后置钩子】← 可以记录日志、更新标题等

三种守卫的详细用法

1. 全局守卫 - 适合处理通用逻辑

全局前置守卫 - 导航触发时调用
router.beforeEach(async (to, from) => {
  console.log('→ 开始导航:', to.path)
  
  // 场景1:检查登录状态
  const userStore = useUserStore()
  const isAuthenticated = userStore.isLoggedIn
  
  // 如果页面需要登录但用户未登录
  if (to.meta.requiresAuth && !isAuthenticated) {
    // 重定向到登录页,并记录要访问的页面
    return {
      path: '/login',
      query: { redirect: to.fullPath }
    }
  }
  
  // 场景2:如果已登录用户访问登录页,跳转到首页
  if (to.path === '/login' && isAuthenticated) {
    return '/'
  }
})
全局解析守卫:所有守卫完成后,导航确认前
router.beforeResolve(async (to, from) => {
  // 适合做数据预加载
  if (to.meta.preload) {
    await to.meta.preload(to)
  }
})
全局后置守卫:导航完成后调用
router.afterEach((to, from, failure) => {
  console.log('← 导航完成:', to.path)
  
  // 场景1:更新页面标题
  if (to.meta.title) {
    document.title = `${to.meta.title} - 我的应用`
  }
  
  // 场景2:页面访问统计
  if (!failure) {
    sendAnalytics({
      page: to.path,
      title: to.meta.title,
      referrer: from.path
    })
  }
  
  // 场景3:滚动到顶部
  window.scrollTo(0, 0)
})

2. 路由独享守卫 - 只对特定路由生效

const routes = [
  {
    path: '/admin',
    component: () => import('@/views/Admin.vue'),
    beforeEnter: (to, from) => {
      // 只在这个路由进入时触发
      // 参数变化时不会触发
      
      // 检查权限
      const userStore = useUserStore()
      if (!userStore.isAdmin) {
        return { path: '/403' }
      }
    }
  },
  {
    path: '/user/:id',
    component: () => import('@/views/User.vue'),
    beforeEnter: [
      // 可以传入数组,按顺序执行
      checkUserExists,
      checkUserStatus,
      logUserAccess
    ]
  }
]

// 独立的守卫函数
async function checkUserExists(to, from) {
  const userStore = useUserStore()
  const exists = await userStore.checkExists(to.params.id)
  if (!exists) {
    return { path: '/404' }
  }
}

3. 组件内守卫 - 处理组件相关的逻辑

1. 离开当前组件时调用
onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value) {
    const answer = window.confirm('有未保存的更改,确定离开吗?')
    if (!answer) return false
  }
})

2. 路由参数变化但组件复用时调用

onBeforeRouteUpdate(async (to, from) => {
  console.log('路由参数变化', to.params, from.params)
  
  // 当路由参数变化时,重新获取数据
  if (to.params.id !== from.params.id) {
    const userId = to.params.id as string
    await fetchUserData(userId)
  }
})

3. 选项式 API 中的 beforeRouteEnter

export default {
  beforeRouteEnter(to, from, next) {
    // ⚠️ 注意:此时不能访问组件实例this
    // 因为组件还没创建
    
    // 可以通过next回调访问实例
    next(vm => {
      // vm就是组件实例
      vm.loadData()
    })
  },
  
  beforeRouteUpdate(to, from) {
    // 可以访问this
    this.fetchData(to.params.id)
  },
  
  beforeRouteLeave(to, from) {
    // 可以访问this
    if (this.hasUnsavedChanges) {
      return confirm('确定离开吗?')
    }
  }
}

导航守卫的实战模式

模式一:权限检查统一处理

// router/guards/permission.ts
import { useUserStore } from '@/stores/user'

export async function permissionGuard(to, from) {
  const userStore = useUserStore()
  
  // 不需要登录的页面
  const publicPages = ['/login', '/register', '/forgot-password']
  if (publicPages.includes(to.path)) {
    return true
  }
  
  // 检查是否登录
  if (!userStore.isLoggedIn) {
    return {
      path: '/login',
      query: { redirect: to.fullPath }
    }
  }
  
  // 检查角色权限
  const requiredRoles = to.meta.roles as string[]
  if (requiredRoles) {
    const hasRole = requiredRoles.some(role => 
      userStore.roles.includes(role)
    )
    if (!hasRole) {
      return { path: '/403' }
    }
  }
  
  // 检查权限点
  const requiredPermissions = to.meta.permissions as string[]
  if (requiredPermissions) {
    const hasPermission = requiredPermissions.every(perm => 
      userStore.permissions.includes(perm)
    )
    if (!hasPermission) {
      return { path: '/403' }
    }
  }
}

模式二:页面数据预加载

// router/guards/prefetch.ts
import { useLoadingStore } from '@/stores/loading'

export async function prefetchGuard(to, from) {
  // 如果路由配置了需要预加载的数据
  if (to.meta.prefetch) {
    const loadingStore = useLoadingStore()
    
    try {
      loadingStore.start()
      
      // 执行预加载函数
      if (typeof to.meta.prefetch === 'function') {
        await to.meta.prefetch(to)
      }
    } finally {
      loadingStore.stop()
    }
  }
}

// 在路由配置中使用
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: {
      prefetch: async (to) => {
        const userStore = useUserStore()
        const dashboardStore = useDashboardStore()
        
        // 并行预加载多个数据
        await Promise.all([
          userStore.fetchProfile(),
          dashboardStore.fetchStats(),
          dashboardStore.fetchCharts()
        ])
      }
    }
  }
]

模式三:页面切换进度条

// router/guards/progress.ts
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

NProgress.configure({
  minimum: 0.1,
  easing: 'ease',
  speed: 500,
  showSpinner: false
})

export function setupProgressGuard(router) {
  let timer: NodeJS.Timeout
  
  router.beforeEach(() => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      NProgress.start()
    }, 200) // 延迟200ms显示,避免快速切换时闪烁
  })
  
  router.afterEach(() => {
    clearTimeout(timer)
    NProgress.done()
  })
  
  router.onError(() => {
    clearTimeout(timer)
    NProgress.done()
  })
}

模式四:页面访问日志

// router/guards/logger.ts
export function setupLoggerGuard(router) {
  router.beforeEach((to, from) => {
    if (import.meta.env.DEV) {
      console.group('🚀 路由导航')
      console.log('从:', from.fullPath || '首次访问')
      console.log('到:', to.fullPath)
      console.log('时间:', new Date().toLocaleString())
      console.log('元信息:', to.meta)
      console.groupEnd()
    }
  })
  
  router.afterEach((to, from, failure) => {
    if (import.meta.env.DEV) {
      if (failure) {
        console.error('❌ 导航失败:', failure)
      } else {
        console.log('✅ 导航成功')
      }
    }
  })
}

路由元信息:路由的隐形背包

什么是路由元信息?

**路由元信息(meta)**是附加在路由配置上的自定义数据,想象每个路由都有一个“背包”,我们可以往里面放任何我们需要的东西,可以包含任何业务需要的字段:

const routes = [
  {
    path: '/admin',
    component: AdminLayout,
    meta: {
      requiresAuth: true,      // 需要登录
      roles: ['admin'],        // 允许的角色
      title: '管理后台',        // 页面标题
      icon: 'admin-icon',      // 菜单图标
      keepAlive: true,         // 需要缓存
      transition: 'fade',       // 切换动画
      breadcrumb: [            // 面包屑
        { name: '首页', path: '/' },
        { name: '管理' }
      ],
      permissions: [           // 权限点
        'user:view',
        'user:edit'
      ]
    },
    children: [
      {
        path: 'users',
        component: UserList,
        meta: {
          title: '用户管理',
          icon: 'user-icon'
        }
      }
    ]
  }
]

元信息的合并策略

// 嵌套路由中的元信息会合并(对象合并,不是覆盖)
const routes = [
  {
    path: '/dashboard',
    meta: { 
      requiresAuth: true, 
      title: '仪表盘',
      breadcrumb: ['首页']
    },
    children: [
      {
        path: 'analytics',
        meta: { 
          title: '数据分析',     // 覆盖父级 title
          breadcrumb: ['首页', '分析']  // 追加到父级 breadcrumb
        },
        component: Analytics
      }
    ]
  }
]

// 最终 Analytics 的 meta:
// {
//   requiresAuth: true,
//   title: '数据分析',
//   breadcrumb: ['首页', '分析']
// }

元信息的高效运用

场景一:动态页面标题

// router/index.ts
router.afterEach((to) => {
  // 获取路由的标题元信息
  const title = to.meta.title as string
  const appName = import.meta.env.VITE_APP_NAME
  
  if (title) {
    document.title = `${title} - ${appName}`
  } else {
    document.title = appName
  }
})

场景二:控制页面缓存

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <!-- 使用keep-alive缓存需要缓存的页面 -->
    <keep-alive :include="cachedViews">
      <component 
        :is="Component" 
        v-if="route.meta.keepAlive"
        :key="route.fullPath"
      />
    </keep-alive>
    
    <!-- 不需要缓存的页面 -->
    <component 
      :is="Component" 
      v-else
      :key="route.fullPath"
    />
  </router-view>
</template>

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

const route = useRoute()

// 获取所有需要缓存的视图名称
const cachedViews = computed(() => {
  return route.matched
    .filter(r => r.meta.keepAlive)
    .map(r => r.components?.default.name)
    .filter(Boolean)
})
</script>

场景三:动态菜单生成

// utils/menu.ts
export function generateMenu(routes, parentPath = '') {
  return routes
    .filter(route => !route.meta?.hidden)  // 过滤隐藏菜单
    .filter(route => route.meta?.title)    // 必须有标题
    .map(route => {
      const fullPath = parentPath + route.path
      
      const menuItem = {
        key: fullPath,
        title: route.meta.title,
        icon: route.meta.icon,
        children: [],
        permissions: route.meta.permissions || []
      }
      
      if (route.children) {
        menuItem.children = generateMenu(route.children, fullPath + '/')
      }
      
      return menuItem
    })
}

// 在组件中使用
const menuList = computed(() => {
  const userStore = useUserStore()
  const routes = router.getRoutes()
  
  return generateMenu(routes).filter(menu => {
    // 过滤没有权限的菜单
    if (menu.permissions.length) {
      return menu.permissions.every(p => userStore.hasPermission(p))
    }
    return true
  })
})

场景四:动态面包屑

// composables/useBreadcrumb.ts
import { computed } from 'vue'
import { useRoute } from 'vue-router'

export function useBreadcrumb() {
  const route = useRoute()
  
  const breadcrumbs = computed(() => {
    const matched = route.matched.filter(item => item.meta?.breadcrumb)
    
    // 收集所有的面包屑
    const items: Array<{ title: string; path?: string }> = []
    
    matched.forEach((item, index) => {
      const bc = item.meta.breadcrumb
      
      if (Array.isArray(bc)) {
        // 如果是数组,直接添加
        bc.forEach((crumb, i) => {
          // 最后一个面包屑不需要路径
          if (index === matched.length - 1 && i === bc.length - 1) {
            items.push({ title: crumb.title })
          } else {
            items.push(crumb)
          }
        })
      } else if (typeof bc === 'string') {
        // 如果是字符串,转换为对象
        if (index === matched.length - 1) {
          items.push({ title: bc })
        } else {
          items.push({ title: bc, path: item.path })
        }
      }
    })
    
    return items
  })
  
  return { breadcrumbs }
}

场景五:路由切换动画

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <transition :name="route.meta.transition || 'fade'" mode="out-in">
      <component :is="Component" :key="route.fullPath" />
    </transition>
  </router-view>
</template>

<style>
/* 基础动画 */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

/* 滑动动画 */
.slide-enter-active,
.slide-leave-active {
  transition: transform 0.3s ease;
}

.slide-enter-from {
  transform: translateX(100%);
}

.slide-leave-to {
  transform: translateX(-100%);
}

/* 缩放动画 */
.scale-enter-active,
.scale-leave-active {
  transition: transform 0.2s ease, opacity 0.2s ease;
}

.scale-enter-from {
  transform: scale(0.9);
  opacity: 0;
}

.scale-leave-to {
  transform: scale(1.1);
  opacity: 0;
}
</style>

路由性能优化策略

组件缓存策略

<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive :include="cachedViews" :max="10">
      <component 
        :is="Component" 
        :key="route.fullPath"
        v-if="route.meta.keepAlive"
      />
    </keep-alive>
    
    <component 
      :is="Component" 
      :key="route.fullPath"
      v-else
    />
  </router-view>
</template>

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

const route = useRoute()
const cachedViews = ref([])

// 动态管理缓存
watch(() => route.meta.keepAlive, (keepAlive) => {
  if (keepAlive && route.name) {
    if (!cachedViews.value.includes(route.name)) {
      cachedViews.value.push(route.name)
    }
  }
}, { immediate: true })

// 监听路由离开,清理不需要的缓存
watch(() => route.fullPath, (newPath, oldPath) => {
  // 如果离开的页面不需要缓存,从缓存中移除
  if (route.matched.some(r => r.meta.keepAlive === false)) {
    const componentName = route.matched[route.matched.length - 1]?.components?.default?.name
    if (componentName) {
      cachedViews.value = cachedViews.value.filter(name => name !== componentName)
    }
  }
})
</script>

数据预加载策略

策略一:路由守卫中预加载

router.beforeResolve(async (to) => {
  if (to.meta.prefetch) {
    const start = performance.now()
    
    // 显示加载状态
    const loading = ElLoading.service({
      fullscreen: true,
      text: '加载中...'
    })
    
    try {
      await to.meta.prefetch(to)
    } finally {
      loading.close()
      
      const end = performance.now()
      console.log(`预加载耗时: ${(end - start).toFixed(2)}ms`)
    }
  }
})

策略二:组件内预加载

import { onBeforeRouteUpdate } from 'vue-router'

// 路由参数变化时重新获取数据
onBeforeRouteUpdate(async (to, from) => {
  if (to.params.id !== from.params.id) {
    // 显示骨架屏
    showSkeleton.value = true
    
    try {
      await fetchData(to.params.id)
    } finally {
      showSkeleton.value = false
    }
  }
})

// 初始加载
await fetchData(route.params.id)

策略三:路由元信息配置预加载函数

const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: {
      prefetch: async () => {
        // 并行预加载多个数据
        await Promise.all([
          useDashboardStore().fetchStats(),
          useDashboardStore().fetchCharts(),
          useUserStore().fetchProfile()
        ])
      }
    }
  }
]

滚动行为优化

const router = createRouter({
  scrollBehavior(to, from, savedPosition) {
    // 返回上一页时恢复滚动位置
    if (savedPosition) {
      // 延迟执行,等待页面渲染完成
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve(savedPosition)
        }, 100)
      })
    }
    
    // 有hash时滚动到对应元素
    if (to.hash) {
      return {
        el: to.hash,
        behavior: 'smooth',
        top: 80 // 减去固定头部的高度
      }
    }
    
    // 不同路由使用不同的滚动行为
    if (to.meta.scrollToTop === false) {
      return {} // 保持当前位置
    }
    
    // 默认滚动到顶部
    return { 
      top: 0, 
      left: 0,
      behavior: 'smooth'
    }
  }
})

路由解析性能监控

// 开发环境下监控路由性能
if (import.meta.env.DEV) {
  router.beforeEach((to) => {
    to.meta.startTime = performance.now()
  })
  
  router.afterEach((to) => {
    const end = performance.now()
    const start = to.meta.startTime
    const duration = (end - start).toFixed(2)
    
    console.log(`✅ 路由 ${to.path} 加载完成: ${duration}ms`)
    
    // 如果超过阈值,记录警告
    if (duration > 500) {
      console.warn(`⚠️ 路由加载较慢: ${duration}ms`)
      
      // 分析哪个部分耗时
      const matched = to.matched
      matched.forEach(record => {
        if (record.components?.default) {
          const comp = record.components.default
          if (typeof comp === 'function') {
            console.log(`  组件 ${record.path} 是懒加载的`)
          }
        }
      })
    }
  })
  
  // 监控组件加载时间
  router.beforeResolve((to) => {
    const components = to.matched.map(record => 
      record.components?.default?.name || record.path
    )
    console.log('即将加载组件:', components)
  })
}

路由性能优化策略

路由组件缓存策略

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <!-- 使用 include 精确控制缓存 -->
    <keep-alive :include="cachedViews" :max="10">
      <component 
        :is="Component" 
        :key="route.fullPath"
      />
    </keep-alive>
  </router-view>
</template>

<script setup>
import { useCacheStore } from '@/stores/cache'

const cacheStore = useCacheStore()

// 动态控制需要缓存的视图
const cachedViews = computed(() => {
  return cacheStore.cachedViews
})

// 手动清除缓存
function clearCache(routeName) {
  cacheStore.removeCachedView(routeName)
}

// 监听路由变化,动态添加/移除缓存
watch(route, (to, from) => {
  // 如果离开的页面需要缓存
  if (from.meta?.keepAlive) {
    cacheStore.addCachedView(from.name)
  }
  
  // 如果进入的页面不需要缓存,且之前缓存了
  if (!to.meta?.keepAlive && cacheStore.hasCachedView(to.name)) {
    cacheStore.removeCachedView(to.name)
  }
})
</script>

路由切换时的数据预加载

方案一:路由守卫中预加载

router.beforeEach(async (to, from) => {
  if (to.meta.preload) {
    const start = performance.now()
    await to.meta.preload(to)
    const end = performance.now()
    console.log(`预加载耗时: ${(end - start).toFixed(2)}ms`)
  }
})

方案二:组件内预加载

// 路由参数变化时重新获取数据
onBeforeRouteUpdate(async (to, from) => {
  if (to.params.page !== from.params.page) {
    await fetchData(to.params.page)
  }
})

// 使用 Suspense 预加载
await fetchData()

方案三:路由元信息配置预加载

const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: {
      preload: async () => {
        // 并行预加载多个数据
        await Promise.all([
          useDashboardStore().fetchStats(),
          useDashboardStore().fetchCharts(),
          useUserStore().fetchProfile()
        ])
      }
    }
  }
]

路由过渡动画的性能优化

/* 使用 transform 代替 left/top 触发硬件加速 */
.slide-enter-active,
.slide-leave-active {
  transition: transform 0.3s ease;
  transform: translate3d(0, 0, 0); /* 开启硬件加速 */
  will-change: transform; /* 提示浏览器优化 */
}

/* 避免同时动画太多元素 */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s ease;
  /* 只动画 opacity,性能更好 */
  will-change: opacity;
}

/* 使用 CSS 动画代替 JS 动画 */
@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

.fade-enter-active {
  animation: fadeIn 0.3s ease;
}

滚动行为的优化

const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    // 返回上一页时恢复滚动位置
    if (savedPosition) {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve(savedPosition)
        }, 100) // 延迟100ms,等待页面渲染完成
      })
    }
    
    // 有 hash 时滚动到对应元素
    if (to.hash) {
      return {
        el: to.hash,
        behavior: 'smooth',  // 平滑滚动
        top: 80 // 考虑固定头部的高度
      }
    }
    
    // 默认滚动到顶部
    return { 
      top: 0, 
      left: 0,
      behavior: 'smooth' 
    }
  }
})

路由解析的性能监控

// 开发环境下监控路由解析时间
if (import.meta.env.DEV) {
  router.beforeEach((to, from) => {
    const start = performance.now()
    to.meta.startTime = start
    
    // 记录导航开始
    console.log(`开始导航到: ${to.path}`)
  })
  
  router.afterEach((to, from) => {
    const end = performance.now()
    const start = to.meta.startTime
    const duration = (end - start).toFixed(2)
    
    console.log(`✅ 导航完成: ${to.path} (${duration}ms)`)
    
    // 如果超过阈值,记录警告
    if (duration > 300) {
      console.warn(`⚠️ 路由 ${to.path} 加载较慢: ${duration}ms`)
    }
  })
  
  // 监控组件加载性能
  router.beforeResolve((to) => {
    const components = to.matched.map(record => 
      record.components?.default.name
    ).filter(Boolean)
    
    console.log('即将加载组件:', components)
  })
}

常见问题与解决方案

问题一:重复添加路由导致警告

// ❌ 错误:多次添加相同路由
function addRoutes() {
  asyncRoutes.forEach(route => {
    router.addRoute(route)  // 第二次调用时会警告
  })
}

解决方法1:检查是否已添加

// ✅ 正确:检查是否已添加
function addRoutes() {
  asyncRoutes.forEach(route => {
    // 使用 router.hasRoute 检查
    if (!router.hasRoute(route.name)) {
      router.addRoute(route)
    }
  })
}

解决方法2:先移除再添加

// ✅ 正确:先移除再添加
function updateRoute(route) {
  if (router.hasRoute(route.name)) {
    router.removeRoute(route.name)
  }
  router.addRoute(route)
}

解决方案3:批量添加时使用 addRoute 的 parent 参数

function addChildRoutes(parentName, routes) {
  routes.forEach(route => {
    if (!router.hasRoute(route.name)) {
      router.addRoute(parentName, route)
    }
  })
}

问题2:路由参数变化但组件不更新

<template>
  <div>
    <h2>{{ user?.name }}</h2>
    <p>{{ user?.email }}</p>
  </div>
</template>

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

const route = useRoute()
const user = ref(null)

// ❌ 错误:只在组件创建时获取一次数据
user.value = await fetchUser(route.params.id)
</script>

解决方法1:监听 route.params 的变化

watch(() => route.params.id, async (newId, oldId) => {
  console.log(`ID从 ${oldId} 变为 ${newId}`)
  await fetchData(newId)
}, { immediate: true })

解决方法2:使用 onBeforeRouteUpdate

import { onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteUpdate(async (to, from) => {
  if (to.params.id !== from.params.id) {
    await fetchData(to.params.id)
  }
})

解决方法3:使用 key 强制重新渲染

<router-view :key="route.fullPath" />

问题3:路由守卫中的异步操作导致导航卡顿

// ❌ 错误:守卫中做太多同步操作
router.beforeEach((to) => {
  const start = Date.now()
  while (Date.now() - start < 1000) {
    // 模拟耗时操作 - 会阻塞导航
  }
})

解决方法1:使用异步操作(但要注意异步操作不影响导航)

router.beforeEach(async (to) => {
  // 显示 loading
  const loading = ElLoading.service({
    fullscreen: true,
    text: '加载中...'
  })
  
  try {
    // 执行异步操作
    await loadData()
  } finally {
    // 导航完成后隐藏 loading
    router.afterEach(() => {
      loading.close()
    })
  }
})

解决方法2:使用 nextTick 延迟执行

router.beforeEach((to) => {
  // 先放行导航
  nextTick(() => {
    // 导航完成后执行耗时操作
    doHeavyWork()
  })
})

解决方案3:使用Web Worker处理复杂计算

router.beforeEach((to) => {
  if (to.meta.heavyComputation) {
    const worker = new Worker('/worker.js')
    worker.postMessage(to.meta.data)
    worker.onmessage = (e) => {
      // 处理计算结果
    }
  }
})

问题4:路由懒加载导致的白屏闪烁

解决方法:加载占位动画

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component }">
    <Suspense>
      <template #default>
        <component :is="Component" />
      </template>
      <template #fallback>
        <!-- 加载占位动画 -->
        <div class="page-loading">
          <LoadingSpinner />
          <p>页面加载中...</p>
        </div>
      </template>
    </Suspense>
  </router-view>
</template>

<style>
.page-loading {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  min-height: 400px;
  color: #909399;
}

@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.loading-spinner {
  animation: spin 1s linear infinite;
  font-size: 32px;
}
</style>

问题5:浏览器前进后退时滚动位置丢失

解决方法1:使用 scrollBehavior

const router = createRouter({
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition  // 返回时恢复位置
    }
    return { top: 0 }       // 新页面滚动到顶部
  }
})

解决方案2:手动保存滚动位置

import { onActivated, onDeactivated } from 'vue'

let scrollTop = 0

onDeactivated(() => {
  // 离开时保存滚动位置
  const container = document.querySelector('.scroll-container')
  scrollTop = container?.scrollTop || 0
})

onActivated(() => {
  // 回来时恢复滚动位置
  nextTick(() => {
    const container = document.querySelector('.scroll-container')
    if (container) {
      container.scrollTop = scrollTop
    }
  })
})

路由使用的最佳实践清单

路由设计原则

原则 说明 示例
按功能拆分 将路由按业务模块拆分成独立文件 router/modules/*.ts
懒加载 所有路由组件使用动态导入 () => import('@/views/xxx.vue')
命名路由 使用 name 而不是 path 跳转 router.push({ name: 'User' })
参数验证 在导航守卫中验证路由参数 if (!to.params.id) return '/404'
元信息丰富 把配置都放在 meta 中 meta: { title, requiresAuth }
404放在最后 通配符路由放最后 { path: '/:pathMatch(.*)*', redirect: '/404' }

导航守卫使用原则

原则 说明 示例
职责单一 每个守卫只做一件事 authGuard, permissionGuard
全局守卫通用 认证、日志、进度条 router.beforeEach(authGuard)
路由独享特定 特定路由的权限检查 beforeEnter: checkPermission
组件内守细腻 数据加载、离开确认 onBeforeRouteLeave
避免耗时操作 守卫中不要做同步耗时操作 使用异步或推迟执行
顺序很重要 按依赖关系排列守卫:认证 -> 权限 -> 预加载 router.beforeEach(auth)
返回值明确 返回false/路径/undefined return '/login'

性能优化清单

  • 路由懒加载:所有路由组件使用动态导入
  • 预加载关键路由:使用 meta.preload 配置,预加载用户可能访问的下一个页面
  • 合理使用缓存:合理使用 keep-alive 缓存频繁访问的页面
  • 体验优化:使用 transform 代替位置属性,使用 Suspense 和骨架屏提升用户体验
  • 监控性能:监控路由解析时间,优化慢的路由
  • 滚动优化:优化滚动行为,保存/恢复滚动位置,实现平滑滚动和位置恢复

用户体验清单

  • 进度条:切换页面时显示进度反馈
  • 加载占位:使用 Suspense 处理异步组件
  • 错误处理:统一处理路由错误页面
  • 标题更新:根据 meta.title 更新 document.title
  • 面包屑:根据路由元信息生成面包屑
  • 过渡动画:添加合适的页面切换动画
  • 保存提示:离开页面时提示未保存更改

结语

Vue Router 不仅仅是 URL 和组件的映射,更是整个应用的骨架神经系统,把路由设计好了,整个应用就成功了一半。

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

自动导入 AutoImport:告别手动引入依赖,优化Vue3开发体验

2026年3月13日 22:28

image

前言

  模块化已经是现代 Web 开发必不可少的开发方式,频繁引入依赖包是一个常见的操作。但是,手动引入依赖包往往繁琐,尤其是当依赖包数量较多时,会显著降低开发效率。如果正在用 Vue3 开发项目时,每写一个页面,都要重复引入 ref、reactive等等API。代码开头总是一堆 import 语句,不仅繁琐,还容易因为漏写导致运行时错误。更头疼的是,团队协作时,不同成员可能引入方式不一致,代码风格难以统一。这种重复劳动其实完全可以避免,unplugin-auto-import 插件就是专门解决这个痛点的利器。它能帮助我们在项目中,自动导入常用的使用的第三方库的 API,就可以方便我们开发,提升开发效率。

一、自动导入的价值:不止是少写几行代码

1.1 理解核心

  在深入具体配置之前,我们需要先扭转一个观念:unplugin-auto-import 不仅仅是一个 “帮助开发者写 import 语句” 的工具,更是一个模块解析与依赖管理的智能层。它的核心价值在于,通过声明式的配置,将开发者从繁琐的、重复的模块导入工作中解放出来,同时确保类型安全和代码整洁,但“智能”的前提是精准的规则定义。这感觉就像你每次想用家里的电视遥控器,都得先跑到储物柜里把它拿出来,用完了再放回去。明明遥控器就该放在茶几上,随手就能拿到。unplugin-auto-import 这个插件,干的就是这个“把遥控器放到茶几上”的活儿。它能自动帮你完成这些常用 API 的导入,让你在代码里直接使用 ref、onMounted、useRouter 等,就像它们是天生的全局变量一样。

  并非所以依赖都适合自动导入,项目内的代码可能就不一定适合自动引入。因为自动引入后,就能像全局变量那样直接使用,但从开发的角度就会丢失依赖链路,虽然另外生成了 Typescript 声明文件,IDE 能够正常识别, 但对于新加入项目的小伙伴来说,他们不一定知道是自动引入,因此可能会降低了一些可读性。那么,什么样的内容适合自动引入?简单来说,那些被广泛认知和使用、不用关注实现、不变的内容,不会影响可读性,不会影响开发,不会对开发者心智造成影响,就适合自动引入。

1.2 为什么需要自动导入 🤔

  在传统的前端开发中,我们经常需要手动导入各种函数和组件。在开发过程中我们需要自己去导入ref、reactive、computed等响应式说明,这些重复的导入语句不仅让代码变得冗长,还增加了维护成本。所以为了减少每个文件中的声明,就引入 Auto-Import 去解决这个问题。先看一个对比,这是配置前的典型代码:

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

const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>

  使用 unplugin-auto-import 插件后,不需要再去引入,同样的功能只需要:

<script setup lang="ts">
const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>

  变化看似不大,但实际开发中的体验提升是显著的:

  • 减少认知负担:不用再记忆每个 API 来自哪个包,这些细节交给工具处理。
  • 降低出错概率:不会因为忘记导入某个 API 而出现运行时错误,只需要在配置文件中更新预设,所有文件都会自动适应新的导入方式。
  • 提升编码流畅度:新成员加入项目,不用先花时间熟悉项目的 import 规范,直接开始写业务逻辑即可。想到什么直接写,不用在文件顶部和代码主体间来回跳转。
  • 类型安全的保障:对于 TypeScript 项目,unplugin-auto-import 会自动生成类型声明文件(通常是 auto-imports.d.ts),确保即使没有显式导入,也能获得完整的类型提示和检查。

二、从零开始配置:避开那些常踩的坑

  理论说再多,不如动手试一下。我们先来把插件跑起来,感受一下“开箱即用”的爽快感。

2.1 安装依赖

  在开始动手修改配置文件之前,我们有必要先理清几个核心概念和它们之间的关系。对于 Vite 项目,自动导入生态主要依赖于两个社区明星插件:unplugin-vue-components 和 unplugin-auto-import,以及一个由官方提供的“粘合剂”:unplugin-icons。

  • unplugin-vue-components 的职责是 “自动按需引入Vue组件”,这个插件会在背后悄悄帮你完成导入和注册组件这两件事。
  • unplugin-auto-import 的职责则更进一步,它专注于 “自动导入 Composition API、工具函数等”

  在开始复杂配置前,请确保项目已正确安装并集成了 unplugin-auto-import。对于 Vite 项目,基础安装和引入如下:

npm install --save-dev unplugin-vue-components
npm install --save-dev unplugin-auto-import
npm install --save-dev unplugin-icons

  这里安装的是开发依赖,因为自动导入是构建时和开发时工具,不会打包进生产代码。

2.2 基本配置

  安装好依赖只是第一步,正确的配置才是让一切运转起来的关键。我们将在 vite.config.ts 或 vite.config.js 文件中进行配置,这是配置的入口,在其中引入 AutoImport ,同时配置相关信息。

// vite.config.ts
import { defineConfig } from 'vite'
// 1. 引入 auto-import 插件
import AutoImport from 'unplugin-auto-import/vite'
 
export default defineConfig({
  plugins: [
    AutoImport({ /* options */ }),
  ]
})

  还需要在 tsconfig.json 文件里加入。

{
  "include": ["src/**/*.ts", "src/types/**/*.d.ts"]
}

2.3 核心配置

预设支持

  unplugin-auto-import内置了丰富的预设,支持多种流行库和框架,如Vue、Vue-router、pinia等,这些预设可以通过 imports 选项轻松配置:

AutoImport({
  imports: [ // 选择需要配置的插件
    'vue', // 自动导入 Vue 3 的 Composition API,如 ref, reactive, computed 等
    'vue-router', // 自动导入 Vue Router 4 的 API,如 useRouter, useRoute
    'pinia'// 自动导入 Pinia 的 API,如 defineStore, storeToRefs
  ]
})

  看,配置就是这么简单。核心就是 imports 数组,只要把想自动导入的包名写进去就行。

类型定义生成

  解决了运行时的导入问题,接下来是类型系统的挑战。TypeScript 需要知道这些“凭空出现”的标识符的类型是什么,否则就会报红,失去代码提示,unplugin-auto-import通过生成全局类型声明文件来解决这个问题。

  启用dts选项可以自动生成类型定义文件,提升开发体验:

AutoImport({
  // 为 TypeScript 生成全局类型声明文件
  dts: 'src/type/auto-imports.d.ts',  // 或者设置为 true,会在根目录生成
})

  看,配置就是这么简单。dts 选项是关键,它告诉插件为 TypeScript 生成类型声明文件的位置。有了这个文件,IDE 才能正确识别这些自动导入的变量。如果为 true,则会在导入冲突时,生成一个 auto-imports.d.ts 和一个 components.d.ts(如果用了组件自动导入)。也可以设置为一个自定义的文件名,我个人的习惯是把 dts 文件放在 “src/type” 目录下,并把它加入到 .gitignore 中,因为它是生成文件,不应该被提交。

  为了让 TypeScript 识别这个全局声明文件,还需要确保它被包含在 tsconfig.json 的 include 或 files 配置中。通常,生成的路径会自动被 Vite 的 TypeScript 插件处理,但手动检查一下是好的习惯。

{
  "include": [
    "src/type/auto-imports.d.ts" // 确保这一行存在
  ]
}

  这种机制的美妙之处在于,它实现了开发时无感导入完整的类型安全的完美结合。你既享受了代码的简洁,又没有牺牲TypeScript带来的智能提示和错误检查能力。

ESLint集成

  在真实的工程化项目中,unplugin-auto-import 从来不是孤军奋战。它必须与 TypeScript 编译器、ESLint 代码检查工具完美配合,否则就会陷入“代码能跑,但编辑器一片红”的尴尬境地。ESLint 默认规则会检查未声明的变量,自动导入的变量在源代码中没有显式导入,ESLint 会认为它们是未定义的,从而抛出错误。为了避免 ESLint 报错,可以配置自动生成 ESLint 配置文件:

AutoImport({
  eslintrc: {
    enabled: true, // 开启生成ESLint配置的功能
filepath: './.eslintrc-auto-import.json', // 指定生成的配置文件路径
globalsPropValue: true, // 声明为全局只读变量
  }
})

  插件会在项目根目录生成类型文件 .eslintrc-auto-import.json ,确保该文件在 eslint 配置中被 extends。

// .eslintrc.js 或 .eslintrc.cjs
module.exports = {
  // ... 其他配置
  extends: [
    // ... 其他扩展
    // 添加这行
    './.eslintrc-auto-import.json' // 这是插件生成的另一个文件
  ]
}

  unplugin-auto-import 在生成 dts 文件的同时,通常也会在项目根目录生成一个 .eslintrc-auto-import.json 文件,里面定义了所有自动导入变量的规则。把它包含进你的 ESLint 配置,ESLint 就知道这些变量是全局可用的,不会报错误。

三、深度定制:让自动导入更贴合项目

  基础配置只能算“能用”,但要想“好用”,还得根据项目情况深度定制。unplugin-auto-import 提供了非常灵活的配置项,让我们一起来看看。

3.1 导入更多生态库

  现代 Vue 3 项目很少只用到核心库,工具库如 VueUse,UI 组件库如 Element Plus 的某些工具函数,都可以纳入自动导入。

import AutoImport from 'unplugin-auto-import/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
import IconsResolver from 'unplugin-icons/resolver';

AutoImport({
  imports: [
    'vue',
    'vue-router',
    'pinia',
    // 添加 VueUse,它是一个函数工具集合
    '@vueuse/core',
  ],
   resolvers: [
      // 自动导入 Element Plus 相关函数
      ElementPlusResolver(),
      IconsResolver({
        prefix: 'Icon'
      })
    ],
  dts: 'src/auto-imports.d.ts',
})

  对于 Element Plus 的这类 API,配置会稍微复杂一点,通常需要结合 unplugin-vue-components(用于自动导入组件)和 resolvers 选项,但核心思想不变:把重复的 import 从代码中抹去。

3.2 自动导入项目本地工具函数

  这才是提升团队开发效率的大杀器。想象一下,项目里封装了很多好用的 useFetch、useTable、useModal 这样的组合式函数,散落在 src/hooks 目录下。以前用的时候,总要去找路径然后导入。现在,可以全部自动导入。

AutoImport({
  imports: [ ... ], // 第三方库
  dts: 'src/auto-imports.d.ts',
  // 关键配置:自动扫描指定目录下的文件
  dirs: [
    './src/stores', // 自动导入 Pinia store 的 useStore 函数
    './src/utils', // 自动导入工具函数
    './src/hooks/**', // 使用 glob 模式匹配子目录
  ],
})

  配置好 dirs 后,插件会在构建时扫描这些目录下的 ts、js 文件,将默认导出的函数或变量自动添加到全局可用列表中。比如在 src/hooks/useDarkMode.ts 里导出了一个 useDarkMode 函数,那么在任意组件中,你就可以直接 const { isDark, toggle } = useDarkMode(),无需导入。

3.3 精细化控制

  随着导入的东西越来越多,生成的 auto-imports.d.ts 文件可能会非常庞大,有时会影响 IDE 性能(虽然通常影响不大),这就可以通过一些配置进行优化。

按需导入

  如果你觉得 VueUse 全部导入太多,可以只导入你确定会用到的。

imports: [
  'vue',
  'vue-router',
  'pinia',
  {
    '@vueuse/core': [
      'useMouse',
      'useLocalStorage',
      'useDark',
      'useClipboard',
      'useDebounceFn',
    ],
  }
]

解决命名冲突

  如果两个库导出了同名的函数(虽然少见),或者你不想用默认的变量名,可以使用 alias 配置别名。

AutoImport({
  imports: [
    { 'vue-router': ['useRouter', 'useRoute'] },
    { 'my-router': ['useRouter as useMyRouter'] }, // 假设有另一个库
  ]
})

四、总结

  unplugin-auto-import作为一款强大的自动导入工具,通过智能化的模块分析和导入管理,彻底改变了传统的手动import方式。它不仅支持多种构建工具和框架,还提供了丰富的自定义选项,满足不同项目的需求。无论是小型应用还是大型项目,unplugin-auto-import都能显著提升开发效率,让开发者更专注于业务逻辑的实现。

image

昨天 — 2026年3月13日首页

5年前端,我为什么要all in AI Agent?

作者 wuhen_n
2026年3月13日 17:27

一个普通 Vue/Electron 工程师的转型自白


前言

我是一个普通的前端工程师。5年经验,公司开发框架也就是 Vue2/3 + TS。自己倒腾过Electron,uni-app,能写一些简单的功能模块。没进过大厂,没写过框架,不是什么技术大神。

每天的生活就是:上班写代码,下班刷掘金/知乎/CSDN,周末偶尔看看新技术。工资一般般,饿不死,也富不了。原本以为自己会这样一直干到退休。

直到去年,年奖金只有以往的一半了!


某一天的顿悟

和往常一样,看看手机,刷刷帖子,直到刷到:《35岁程序员裸辞两月,找不到工作,感慨程序员是碗青春饭》。

那一刻,我突然意识到:我也30多了,离35也不远了。

那天晚上,我失眠了。翻来覆去想几个问题:

  • 我的核心竞争力是什么?
  • 如果明天被裁,我能做什么?
  • 5年后,我还在写代码吗?

没有答案。只有焦虑。


一次偶然的机遇

随着公司业务发展,各部门都在鼓励使用 AI,自然而然的,我也分配了相关的任务:处理后端 AI 大模型的流式返回数据。这本来也是很简单的需求:

  • fetchEventSource 发送请求
  • onMessage() 接收并处理数据
  • onError() 处理异常/错误情况

此时,问题来了:后端返回的并不是的 text/event-stream 格式,而是 application/json;在网上查了一圈,知道可以在 onOpen() 里重新发一条 GET 请求来解决这个问题。

然后,又出现新的问题了:后端异常没有正常返回,全部要前端处理!烦躁之下,我打开了DeepSeek(之前只用它写过文档),10 秒钟,它给出了完整的解决方案。

我当时震惊的不是它写出来了,而是它写出来的代码,完全符合我的需求,而且比我预设需求的还要完整!

那一刻,我突然意识到:这东西真方便。

后来的开发中,我开始疯狂用它:

  • 让它生成Vue3组件,它懂得用<script setup>,知道我喜欢用ref而不是reactive
  • 让它写TS类型,它知道我的命名规范(IPropsTResponse
  • 让它解释一段看不懂的配置,它讲得比文档还清楚

我开始想:如果 AI 这么懂前端,那我是不是可以用它做更多事?


AI突围战”

从那天起,我给自己定了一个目标:用4个月时间,成为一个会“玩AI”的前端。

我不学 PyTorch,不学 Transformer,不调模型参数。我的路线很简单:

阶段 目标 时间
第一阶段 学会让AI按我的要求生成代码(Prompt工程) 3周
第二阶段 打通Electron + AI API,做桌面工具 4周
第三阶段 让AI能调用我写的函数(Function Calling) 6周
第四阶段 做一个真正能“干活”的Agent 8周

这4个月,我经历了什么?

第一周:信心满满 → 被 Prompt 折磨(AI 就是不按格式输出)
第二周:第一个 Electron + AI 应用跑通 → 激动得发朋友圈
第四周:Function Calling 总是失败 → 怀疑人生
第六周:第一个能用的 Agent 诞生(帮我处理Git) → 比发工资还开心
第十周:做的一个桌面助手被吐槽“鸡肋” → 反思产品思维
第十六周:现在,我能用Cursor + AI 在30分钟内开发一个小工具

这4个月,我学会了什么?

  • 不是:模型原理、Attention机制、微调技术
  • 而是:怎么让AI听我的话、怎么把AI集成进Electron、怎么让AI调用我的函数、怎么用AI帮我写代码

最重要的是:我不焦虑了,因为我知道,AI时代,前端不仅没有被淘汰,反而有了新的机会。


为什么说前端是AI时代的“天选之子”?

这4个月让我想明白一件事:

AI 是发动机,前端是驾驶舱。发动机很重要,但用户接触的是驾驶舱。

我们的优势是什么?

优势 说明
UI/UX思维 我们知道怎么让AI的“答案”变成好用的“产品”
TypeScript 严格的类型定义,让 AI 生成的代码更可控
Electron经验 桌面端是 AI 的下一站(隐私、离线、本地资源)
工程化能力 组件化、模块化,这些思维在 AI 应用开发中同样重要

我不是在安慰自己,而是在这4个月我见过太多 AI 应用翻车的案例:

  • 技术很强,但 UI 一塌糊涂 → 没人用
  • 模型很准,但交互反人类 → 用户流失
  • 功能很多,但不会产品化 → 自嗨

这些都是我们前端擅长的地方。


这个专栏要写什么

所以,我开了这个专栏。

它不是:

  • ❌ 大模型原理讲解(我不懂)
  • ❌ Python/PyTorch教程(我不会)
  • ❌ 教你成为AI科学家(做不到)

它是:

  • 一个普通前端的真实转型记录(不装逼,只记录自己踩过的坑)
  • 前端视角的AI应用开发实战(Vue3/TS/Electron)
  • Agent和Vibe Coding的落地经验(能跑起来的代码)

结语

前端已死”,这句话从10年前就开始兴起了,“死”了这么多年还没死透,我认为它就是有价值的。现如今,在 AI 的加持,对前端的要求会越来越高,但这条路我会继续走下去,也会把每一步都记录下来。

如果你也在这条路上,欢迎同行。

Vue 动态表单(Dynamic Form)

作者 小霍同学
2026年3月13日 15:29

Vue 动态表单(Dynamic Form)

动态表单是指根据数据配置(如 JSON 或 JavaScript 对象)来动态生成表单字段的组件。它能够极大地提高开发效率,减少重复代码,尤其适用于字段频繁变化、需要配置化的场景,如后台管理系统、问卷生成器、自定义表单等。

什么是动态表单

传统的表单开发中,每个字段都需要在模板中手动编写 <input><select> 等标签,并绑定对应的 v-model 和验证规则。而动态表单通过配置驱动的方式,将字段的元数据(类型、标签、验证规则、布局等)抽象为一个数组或对象,然后使用 Vue 的渲染能力(如 v-for)循环生成表单元素。

核心思想: 将表单的结构与实现分离,通过修改配置即可调整表单,无需修改模板代码。

为什么需要动态表单

  • 提高开发效率:减少模板代码的编写,尤其是表单字段数量大、变化频繁的场景。
  • 增强可维护性:表单结构集中在配置中,修改字段只需调整配置项。
  • 支持配置化/可视化:可与后台接口配合,实现由后端返回表单配置的动态表单;也可用于拖拽式表单设计器。
  • 易于扩展:增加新的字段类型只需在渲染函数中添加对应组件,不影响现有逻辑。

基础实现

定义字段配置

首先,我们需要定义一组字段配置,每个字段包含类型、标签、字段名、默认值等信息。

// formConfig.js
export const fields = [  {    type: 'input',    label: '用户名',    field: 'username',    placeholder: '请输入用户名',    defaultValue: ''  },  {    type: 'select',    label: '性别',    field: 'gender',    options: [      { label: '男', value: 1 },      { label: '女', value: 2 }    ],
    defaultValue: 1
  },
  {
    type: 'radio',
    label: '爱好',
    field: 'hobby',
    options: [
      { label: '读书', value: 'book' },
      { label: '运动', value: 'sport' }
    ],
    defaultValue: 'book'
  },
  {
    type: 'checkbox',
    label: '技能',
    field: 'skills',
    options: [
      { label: 'Vue', value: 'vue' },
      { label: 'React', value: 'react' }
    ],
    defaultValue: ['vue']
  }
]

渲染表单

在 Vue 组件中,使用 v-for 遍历配置,根据 type 动态渲染不同的表单项。为了简化,我们可以用 v-if / v-else-if 判断,或者使用动态组件 <component :is="...">

<template>
  <form @submit.prevent="handleSubmit">
    <div v-for="field in fields" :key="field.field" class="form-item">
      <label :for="field.field">{{ field.label }}</label>
      
      <!-- 根据字段类型渲染不同控件 -->
      <input
        v-if="field.type === 'input'"
        :id="field.field"
        v-model="formData[field.field]"
        :placeholder="field.placeholder"
      />
      
      <select
        v-else-if="field.type === 'select'"
        :id="field.field"
        v-model="formData[field.field]"
      >
        <option v-for="opt in field.options" :key="opt.value" :value="opt.value">
          {{ opt.label }}
        </option>
      </select>
      
      <div v-else-if="field.type === 'radio'">
        <label v-for="opt in field.options" :key="opt.value">
          <input
            type="radio"
            :name="field.field"
            :value="opt.value"
            v-model="formData[field.field]"
          />
          {{ opt.label }}
        </label>
      </div>
      
      <div v-else-if="field.type === 'checkbox'">
        <label v-for="opt in field.options" :key="opt.value">
          <input
            type="checkbox"
            :value="opt.value"
            v-model="formData[field.field]"
          />
          {{ opt.label }}
        </label>
      </div>
    </div>
    
    <button type="submit">提交</button>
  </form>
</template>

<script setup>
import { ref } from 'vue'
import { fields } from './formConfig'

// 初始化表单数据
const formData = ref({})
fields.forEach(field => {
  formData.value[field.field] = field.defaultValue
})

const handleSubmit = () => {
  console.log('表单数据:', formData.value)
}
</script>

说明:

  • 使用 v-model 绑定到 formData 对象的对应字段。
  • 注意 checkboxv-model 绑定到数组,允许多选。
  • 这种方式简单直观,但当字段类型增多时,模板中的 v-if 会显得臃肿。我们可以进一步优化,使用动态组件。

使用动态组件优化渲染

我们可以为每种字段类型创建一个独立的组件(如 InputField.vueSelectField.vue),然后在模板中用 <component :is="getComponent(field.type)" /> 动态渲染。


<template>
  <form @submit.prevent="handleSubmit">
    <div v-for="field in fields" :key="field.field" class="form-item">
      <label>{{ field.label }}</label>
      <component
        :is="getComponent(field.type)"
        :field="field"
        v-model="formData[field.field]"
      />
    </div>
    <button type="submit">提交</button>
  </form>
</template>

<script setup>
import { ref, markRaw } from 'vue'
import InputField from './components/InputField.vue'
import SelectField from './components/SelectField.vue'
import RadioField from './components/RadioField.vue'
import CheckboxField from './components/CheckboxField.vue'

const fields = [...] // 配置数组

const componentMap = markRaw({
  input: InputField,
  select: SelectField,
  radio: RadioField,
  checkbox: CheckboxField
                
})

const getComponent = (type) => componentMap[type] || null

const formData = ref({})
fields.forEach(field => {
  formData.value[field.field] = field.defaultValue
})

const handleSubmit = () => {
  console.log('表单数据:', formData.value)
}
</script>

每个字段组件接收 field 配置和 modelValue(用于 v-model),内部实现对应的控件。例如 InputField.vue

<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
    v-bind="$attrs"
  />
</template>

<script setup>
defineProps(['modelValue', 'field'])
defineEmits(['update:modelValue'])
</script>

使用动态组件让代码更清晰,扩展新类型只需增加对应的组件,无需修改模板。

进阶功能

表单验证

动态表单的验证可以设计为配置式,例如在字段配置中添加 rules 属性。验证可以在提交时统一执行,也可以实时触发。我们可以使用第三方库如 VeeValidateVuelidate,也可以手动实现。

手动实现简单验证示例:

在字段配置中增加 rules

{
  type: 'input',
  label: '邮箱',
  field: 'email',
  rules: [
    { required: true, message: '邮箱不能为空' },
    { pattern: /^[^\s@]+@[^\s@]+.[^\s@]+$/, message: '邮箱格式不正确' }
  ]
}

在组件中,添加验证逻辑:

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

const validate = () => {
  const newErrors = {}
  fields.forEach(field => {
    if (field.rules) {
      for (const rule of field.rules) {
        if (rule.required && !formData.value[field.field]) {
          newErrors[field.field] = rule.message
          break
        }
        if (rule.pattern && !rule.pattern.test(formData.value[field.field])) {
          newErrors[field.field] = rule.message
          break
        }
      }
    }
  })
  errors.value = newErrors
  return Object.keys(newErrors).length === 0
}

const handleSubmit = () => {
  if (validate()) {
    // 提交
  }
}
</script>

在模板中显示错误信息:

<div v-if="errors[field.field]" class="error">{{ errors[field.field] }}</div>

如果使用 UI 库(如 Element Plus),其表单组件通常自带验证机制,只需将配置传递给相应组件即可。

布局控制

动态表单常常需要灵活的布局,例如栅格系统。可以在字段配置中添加布局属性,如 span(占列数)、offset 等。

{
  type: 'input',
  label: '姓名',
  field: 'name',
  span: 12, // 占12列(假设24栅格)
  // ...
}

在模板中,可以结合 CSS 框架(如 Tailwind、Bootstrap 或 Element Plus 的布局组件)实现动态布局。

以 Element Plus 为例:

<el-form>
  <el-row :gutter="20">
    <el-col v-for="field in fields" :key="field.field" :span="field.span || 24">
      <el-form-item :label="field.label">
        <component
          :is="getComponent(field.type)"
          :field="field"
          v-model="formData[field.field]"
        />
      </el-form-item>
    </el-col>
  </el-row>
</el-form>

字段联动

联动是指一个字段的值变化影响另一个字段的显示、禁用、选项等。可以在配置中定义 dependencies,并在渲染时根据依赖动态计算属性。

实现思路:

  • 在字段配置中添加 visible 函数(或 if 条件),返回布尔值控制显示。
  • 使用 watch 监听依赖字段的变化,动态更新目标字段的配置(如选项列表)。

简单示例:根据选择的“国家”改变“城市”的选项。

{
  type: 'select',
  label: '国家',
  field: 'country',
  options: [...]
},
{
  type: 'select',
  label: '城市',
  field: 'city',
  options: [], // 初始为空
  dependsOn: 'country',
  updateOptions: (country) => {
    // 根据 country 返回新的选项数组
    if (country === 'china') return [{ label: '北京', value: 'beijing' }]
    // ...
  }
}

在组件中,可以定义一个方法监听依赖变化并更新选项。

动态增删字段

某些场景需要允许用户动态添加表单项,例如一组可重复的输入框(如教育经历)。可以在配置中支持 array 类型,使用 v-for 渲染多个相同结构的组。

示例: 动态添加技能列表。

配置:

{
  type: 'dynamic',
  label: '技能列表',
  field: 'skills',
  itemConfig: {
    type: 'input',
    placeholder: '请输入技能'
  },
  defaultValue: ['']
}

渲染时,维护一个数组,并提供添加/删除按钮。

<template>
  <div v-for="(item, index) in formData.skills" :key="index">
    <input v-model="formData.skills[index]" />
    <button @click="removeSkill(index)">删除</button>
  </div>
  <button @click="addSkill">添加技能</button>
</template>

<script setup>
const formData = ref({ skills: [''] })
const addSkill = () => formData.value.skills.push('')
const removeSkill = (index) => formData.value.skills.splice(index, 1)
</script>

结合 UI 库(Element Plus)的完整示例

下面是一个使用 Element Plus 实现的动态表单示例,包含验证和布局。

<template>
  <el-form :model="formData" :rules="rules" ref="formRef" label-width="100px">
    <el-row :gutter="20">
      <el-col
        v-for="field in fields"
        :key="field.field"
        :span="field.span || 24"
        v-if="field.visible ? field.visible(formData) : true"
      >
        <el-form-item
          :label="field.label"
          :prop="field.field"
          :rules="field.rules"
        >
          <!-- 动态组件渲染字段 -->
          <component
            :is="getComponent(field.type)"
            :field="field"
            v-model="formData[field.field]"
            v-bind="field.props"
          />
        </el-form-item>
      </el-col>
    </el-row>
    <el-form-item>
      <el-button type="primary" @click="submitForm">提交</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive, markRaw } from 'vue'
import { ElMessage } from 'element-plus'

// 字段类型映射组件
import ElInput from './components/ElInput.vue'   // 封装 Element Plus 输入框
import ElSelect from './components/ElSelect.vue' // 封装 Element Plus 选择器
// ... 其他组件

const componentMap = markRaw({
  input: ElInput,
  select: ElSelect,
  // ...
})

const getComponent = (type) => componentMap[type]

// 字段配置
const fields = ref([
  {
    type: 'input',
    label: '用户名',
    field: 'username',
    span: 12,
    rules: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
    props: { placeholder: '请输入用户名' }
  },
  {
    type: 'select',
    label: '性别',
    field: 'gender',
    span: 12,
    rules: [{ required: true, message: '请选择性别', trigger: 'change' }],
    options: [
      { label: '男', value: 1 },
      { label: '女', value: 2 }
    ],
    props: { placeholder: '请选择' }
  },
  {
    type: 'input',
    label: '邮箱',
    field: 'email',
    span: 24,
    rules: [
      { required: true, message: '请输入邮箱', trigger: 'blur' },
      { type: 'email', message: '请输入正确的邮箱', trigger: 'blur' }
    ],
    props: { placeholder: '请输入邮箱' }
  }
])

// 表单数据
const formData = ref({})
fields.value.forEach(field => {
  formData.value[field.field] = field.defaultValue ?? ''
})

// 表单引用
const formRef = ref()

const submitForm = async () => {
  if (!formRef.value) return
  await formRef.value.validate((valid, fields) => {
    if (valid) {
      ElMessage.success('提交成功')
      console.log('表单数据:', formData.value)
    } else {
      console.log('验证失败', fields)
    }
  })
}
</script>

其中,封装的组件(如 ElInput.vue)需要适配 Element Plus 的 v-model 用法,并将 field.props 传递给原生组件:

<template>
  <el-input
    :model-value="modelValue"
    @update:model-value="$emit('update:modelValue', $event)"
    v-bind="field.props"
  />
</template>

<script setup>
defineProps(['modelValue', 'field'])
defineEmits(['update:modelValue'])
</script>

注意事项与最佳实践

  • 响应式数据:确保 formData 是响应式的,并在字段变化时能够触发视图更新。
  • 性能优化:如果字段数量很大,考虑使用虚拟滚动或懒加载;避免在模板中放置复杂的计算逻辑。
  • 类型扩展:将字段类型组件设计为可插拔,便于新增类型。
  • 配置标准化:定义统一的字段配置格式,便于维护和文档化。
  • 与后端配合:动态表单常与后端 API 结合,由后端返回表单配置(包括字段、选项、验证规则),前端只需渲染。
  • 可访问性:确保动态生成的表单元素具有正确的 idname 和标签关联,提升无障碍体验。

Vue 动态组件(Dynamic Components)

作者 小霍同学
2026年3月13日 15:26

Vue 动态组件(Dynamic Components)

动态组件是 Vue 中一个非常实用的特性,它允许我们在同一个挂载点(一个 <component> 元素)上动态地切换不同的组件。这种机制使得组件的渲染逻辑更加灵活,尤其在需要根据用户交互或应用状态改变视图时非常有用。

什么是动态组件

简单来说,动态组件就是通过一个特殊的 <component> 元素,并绑定其 is 属性来决定当前要渲染的组件is 属性的值可以是一个已注册的组件名,也可以是一个导入的组件对象。

is 的值发生变化时,Vue 就会销毁旧的组件实例并用新的组件替换。

基本用法

使用 <component> 元素

在模板中,使用 <component> 标签,并通过 :is 绑定要渲染的组件:

<template>
  <component :is="currentComponent"></component>
</template>

<script setup>
import { ref } from 'vue'
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'

const currentComponent = ref(ComponentA) // 也可以使用组件名 'ComponentA'
</script>

:is 的两种绑定方式

  • 绑定组件对象(推荐):直接导入组件并绑定。

    <script setup>
    import { ref } from 'vue'
    import ComponentA from './ComponentA.vue'
    import ComponentB from './ComponentB.vue'
    
    const currentComponent = ref(ComponentA) // 也可以使用组件名 'ComponentA'
    </script>
    
  • 绑定组件名称字符串:组件必须在 components 选项中注册(选项式API)或全局注册。

    <script>
    // 选项式 API 示例
    export default {
      data() {
        return {
          current: 'MyComponent'
        }
      },
      components: {
        MyComponent
      }
    }
    </script>
    

实际示例:通过按钮切换组件

<template>
  <div>
    <button
      v-for="tab in tabs"
      :key="tab"
      @click="currentTab = tab"
      :class="{ active: currentTab === tab }"
    >
      {{ tab }}
    </button>

    <component :is="currentTabComponent" class="tab-content"></component>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import HomeTab from './HomeTab.vue'
import PostsTab from './PostsTab.vue'
import ArchiveTab from './ArchiveTab.vue'

const tabs = ['Home', 'Posts', 'Archive']
const currentTab = ref('Home')

const currentTabComponent = computed(() => {
  switch (currentTab.value) {
    case 'Home': return HomeTab
    case 'Posts': return PostsTab
    case 'Archive': return ArchiveTab
    default: return HomeTab
  }
})
</script>

使用 <keep-alive> 缓存组件状态

默认情况下,每次切换动态组件,Vue 都会销毁旧组件并创建新组件,这意味着组件内部的状态会丢失。如果我们希望保留组件的状态(例如表单输入内容、滚动位置等),可以将 <component> 包裹在 <keep-alive> 标签内。

<template>
  <keep-alive>
    <component :is="currentTabComponent"></component>
  </keep-alive>
</template>

这样,被切换掉的组件会被缓存,而不是销毁。当再次切换回来时,组件会从缓存中恢复,保留之前的状态。

按条件缓存

<keep-alive> 还支持 includeexclude 属性,用于指定哪些组件需要被缓存(通过组件名称匹配)。

<keep-alive include="HomeTab,PostsTab">
  <component :is="currentTabComponent"></component>
</keep-alive>

动态组件与异步组件结合

当应用较大时,我们可以结合 Vue 的异步组件来按需加载,提高首屏加载速度。

<template>
  <component :is="asyncComponent"></component>
</template>

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

const asyncComponent = ref(null)

// 假设在某个时机加载组件
function loadComponent() {
  asyncComponent.value = defineAsyncComponent(() =>
    import('./HeavyComponent.vue')
  )
}
</script>

动态组件的 is 属性可以直接接收一个异步组件工厂函数,Vue 会在需要渲染时自动解析它。

注意事项

is 属性的绑定方式(Vue 2 vs Vue 3)

  • Vue 3:直接使用 :is="组件对象/名称",无需额外指令。
  • Vue 2:动态组件的 is 属性通常写作 :is="componentName",但如果想直接传入组件对象,需要使用 is 属性并配合 v-bind

避免使用 HTML 元素名作为组件名

在 Vue 3 中,如果将 is 绑定到一个 HTML 标签名(如 'div'),Vue 会将其渲染为普通 HTML 元素,而不是 Vue 组件。这通常用于在原生元素上动态切换标签,但如果你想要的是 Vue 组件,请确保绑定的值是组件对象或已注册的组件名称。

XSS 防范

永远不要将用户可编辑的内容直接作为 is 的值(例如通过 v-html 或拼接字符串),否则可能导致 XSS 攻击。应当始终使用受控的组件名或组件对象。

v-if / v-else 的选择

  • 如果只有少数几个固定组件的切换,使用 v-if / v-else-if / v-else 也可以。
  • 当组件的数量不确定或需要动态变化时,动态组件更加简洁。

Vue3 组件封装实战 | 从 0 封装一个可复用的表格组件(附插槽 / Props 设计)

作者 代码煮茶
2026年3月13日 15:13

一、为什么要封装组件?

在企业级项目中,表格是最常见的 UI 形态之一。几乎每个后台管理系统都有大量的表格页面:用户列表、订单管理、商品管理...如果每个页面都重复写表格逻辑,不仅代码冗余,维护成本也极高。

封装表格组件的价值:

  • 提升开发效率:一次封装,多处使用
  • 统一交互体验:分页、排序、筛选行为一致
  • 降低维护成本:修改逻辑只需改一处
  • 代码复用:避免重复造轮子

二、组件设计思路

2.1 需求分析

一个成熟的表格组件应该具备哪些能力?

// 核心功能需求
1. 数据展示:支持列表数据渲染
2. 列配置:自定义列标题、字段、宽度、对齐方式
3. 分页:支持分页器,可配置每页条数
4. 排序:支持单列排序、多列排序
5. 筛选:支持表头筛选
6. 操作列:编辑、删除等操作按钮
7. 自定义内容:插槽支持个性化渲染
8. 加载状态:显示加载中效果
9. 空状态:无数据时显示占位
10. 选择功能:支持行选择(单选/多选)
11. 展开行:支持展开查看更多信息
12. 固定列:左侧/右侧固定列

2.2 组件设计原则

// 1. 单一职责原则
// 表格组件只负责表格渲染,不关心数据获取

// 2. 可配置原则
// 通过 props 提供灵活的配置选项

// 3. 可扩展原则
// 通过插槽支持自定义内容

// 4. 类型安全
// 使用 TypeScript 定义 Props 和事件

三、基础版本实现

3.1 项目初始化

# 创建项目
npm create vite@latest vue3-table-demo -- --template vue-ts

# 安装依赖
npm install element-plus @element-plus/icons-vue

# 启动项目
cd vue3-table-demo
npm run dev

3.2 基础表格组件

<!-- components/BaseTable.vue -->
<template>
  <div class="base-table">
    <!-- 表格主体 -->
    <el-table
      v-loading="loading"
      :data="data"
      :border="border"
      :stripe="stripe"
      :size="size"
      :empty-text="emptyText"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @row-click="handleRowClick"
    >
      <!-- 选择列 -->
      <el-table-column
        v-if="showSelection"
        type="selection"
        width="55"
        fixed="left"
      />
      
      <!-- 序号列 -->
      <el-table-column
        v-if="showIndex"
        type="index"
        width="55"
        label="序号"
        fixed="left"
      />
      
      <!-- 动态渲染列 -->
      <template v-for="column in columns" :key="column.prop">
        <!-- 有自定义插槽的列 -->
        <el-table-column
          v-if="column.slot"
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
        >
          <template #default="{ row, $index }">
            <slot 
              :name="column.slot" 
              :row="row" 
              :index="$index"
              :prop="column.prop"
            >
              {{ row[column.prop] }}
            </slot>
          </template>
        </el-table-column>
        
        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :formatter="column.formatter"
          :show-overflow-tooltip="column.showTooltip"
        />
      </template>
      
      <!-- 操作列(预留插槽) -->
      <el-table-column
        v-if="$slots.action"
        label="操作"
        :width="actionWidth"
        :fixed="actionFixed"
        align="center"
      >
        <template #default="{ row, $index }">
          <slot name="action" :row="row" :index="$index" />
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 分页器 -->
    <div v-if="showPagination" class="table-pagination">
      <el-pagination
        v-model:current-page="currentPage"
        v-model:page-size="pageSize"
        :page-sizes="pageSizes"
        :total="total"
        :layout="paginationLayout"
        background
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </div>
  </div>
</template>

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

// TypeScript 接口定义
export interface TableColumn {
  prop: string                // 字段名
  label: string               // 列标题
  width?: number | string     // 宽度
  align?: 'left' | 'center' | 'right'  // 对齐方式
  fixed?: boolean | 'left' | 'right'   // 固定列
  sortable?: boolean          // 是否可排序
  slot?: string               // 插槽名称
  formatter?: (row: any, column: any, cellValue: any, index: number) => any  // 格式化函数
  showTooltip?: boolean       // 超出是否显示tooltip
}

// Props 定义
const props = defineProps({
  // 表格数据
  data: {
    type: Array as PropType<any[]>,
    required: true,
    default: () => []
  },
  
  // 列配置
  columns: {
    type: Array as PropType<TableColumn[]>,
    required: true,
    default: () => []
  },
  
  // 总条数(用于分页)
  total: {
    type: Number,
    default: 0
  },
  
  // 是否显示分页
  showPagination: {
    type: Boolean,
    default: true
  },
  
  // 当前页码
  page: {
    type: Number,
    default: 1
  },
  
  // 每页条数
  limit: {
    type: Number,
    default: 20
  },
  
  // 每页条数选项
  pageSizes: {
    type: Array as PropType<number[]>,
    default: () => [10, 20, 50, 100]
  },
  
  // 分页布局
  paginationLayout: {
    type: String,
    default: 'total, sizes, prev, pager, next, jumper'
  },
  
  // 是否显示选择列
  showSelection: {
    type: Boolean,
    default: false
  },
  
  // 是否显示序号列
  showIndex: {
    type: Boolean,
    default: false
  },
  
  // 是否显示边框
  border: {
    type: Boolean,
    default: true
  },
  
  // 是否显示斑马纹
  stripe: {
    type: Boolean,
    default: true
  },
  
  // 表格尺寸
  size: {
    type: String as PropType<'large' | 'default' | 'small'>,
    default: 'default'
  },
  
  // 加载状态
  loading: {
    type: Boolean,
    default: false
  },
  
  // 空数据提示
  emptyText: {
    type: String,
    default: '暂无数据'
  },
  
  // 操作列宽度
  actionWidth: {
    type: [Number, String],
    default: 150
  },
  
  // 操作列是否固定
  actionFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'right'
  }
})

// 事件定义
const emit = defineEmits([
  'update:page',
  'update:limit',
  'selection-change',
  'sort-change',
  'row-click',
  'page-change'
])

// 内部状态
const currentPage = ref(props.page)
const pageSize = ref(props.limit)

// 监听外部变化
watch(() => props.page, (val) => {
  currentPage.value = val
})

watch(() => props.limit, (val) => {
  pageSize.value = val
})

// 分页变化处理
const handleSizeChange = (size: number) => {
  pageSize.value = size
  emit('update:limit', size)
  emit('page-change', { page: currentPage.value, limit: size })
}

const handleCurrentChange = (page: number) => {
  currentPage.value = page
  emit('update:page', page)
  emit('page-change', { page, limit: pageSize.value })
}

// 选择变化处理
const handleSelectionChange = (selection: any[]) => {
  emit('selection-change', selection)
}

// 排序变化处理
const handleSortChange = ({ prop, order, column }: any) => {
  emit('sort-change', { prop, order, column })
}

// 行点击处理
const handleRowClick = (row: any, column: any, event: Event) => {
  emit('row-click', { row, column, event })
}

// 暴露方法给父组件
defineExpose({
  // 清除选择
  clearSelection: () => {
    // 通过 ref 调用 el-table 的方法
  },
  
  // 切换某行的选择状态
  toggleRowSelection: (row: any, selected?: boolean) => {
    // 实现...
  }
})
</script>

<style scoped lang="scss">
.base-table {
  width: 100%;
  
  .table-pagination {
    margin-top: 20px;
    display: flex;
    justify-content: flex-end;
  }
}
</style>

四、增强版封装(企业级)

4.1 高级表格组件

<!-- components/ProTable.vue -->
<template>
  <div class="pro-table">
    <!-- 工具栏 -->
    <div v-if="showToolbar" class="table-toolbar">
      <div class="toolbar-left">
        <slot name="toolbar-left">
          <span class="table-title">{{ title }}</span>
        </slot>
      </div>
      
      <div class="toolbar-right">
        <slot name="toolbar-right">
          <!-- 刷新按钮 -->
          <el-button 
            v-if="showRefresh" 
            :icon="Refresh" 
            circle 
            @click="handleRefresh"
          />
          
          <!-- 密度切换 -->
          <el-dropdown v-if="showDensity" @command="handleDensityChange">
            <el-button :icon="Grid" circle />
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item command="large">宽松</el-dropdown-item>
                <el-dropdown-item command="default">默认</el-dropdown-item>
                <el-dropdown-item command="small">紧凑</el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
          
          <!-- 列设置 -->
          <el-popover
            v-if="showColumnSetting"
            placement="bottom-end"
            :width="200"
            trigger="click"
          >
            <template #reference>
              <el-button :icon="Setting" circle />
            </template>
            
            <div class="column-setting">
              <div class="setting-header">
                <span>列展示</span>
                <el-checkbox 
                  v-model="checkAll" 
                  :indeterminate="isIndeterminate"
                  @change="handleCheckAllChange"
                >
                  全选
                </el-checkbox>
              </div>
              <el-divider />
              <el-checkbox-group v-model="checkedColumns" @change="handleCheckedChange">
                <div v-for="col in allColumns" :key="col.prop" class="setting-item">
                  <el-checkbox :label="col.prop">
                    {{ col.label }}
                  </el-checkbox>
                  <el-icon class="drag-icon"><Rank /></el-icon>
                </div>
              </el-checkbox-group>
            </div>
          </el-popover>
        </slot>
      </div>
    </div>
    
    <!-- 表格主体 -->
    <el-table
      ref="tableRef"
      v-loading="loading"
      :data="filteredData"
      :border="border"
      :stripe="stripe"
      :size="tableSize"
      :empty-text="emptyText"
      :row-key="rowKey"
      :expand-row-keys="expandRowKeys"
      :default-sort="defaultSort"
      :span-method="spanMethod"
      :row-class-name="rowClassName"
      :cell-class-name="cellClassName"
      :header-row-class-name="headerRowClassName"
      :header-cell-class-name="headerCellClassName"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @row-click="handleRowClick"
      @row-dblclick="handleRowDblClick"
      @expand-change="handleExpandChange"
    >
      <!-- 展开行 -->
      <el-table-column
        v-if="showExpand"
        type="expand"
        width="50"
      >
        <template #default="{ row }">
          <slot name="expand" :row="row" />
        </template>
      </el-table-column>
      
      <!-- 选择列 -->
      <el-table-column
        v-if="showSelection"
        type="selection"
        :width="selectionWidth"
        :fixed="selectionFixed"
        :selectable="selectable"
        :reserve-selection="reserveSelection"
      />
      
      <!-- 序号列 -->
      <el-table-column
        v-if="showIndex"
        type="index"
        :width="indexWidth"
        :label="indexLabel"
        :fixed="indexFixed"
        :index="indexMethod"
      />
      
      <!-- 动态渲染列(支持拖拽排序) -->
      <template v-for="column in visibleColumns" :key="column.prop">
        <!-- 有自定义插槽的列 -->
        <el-table-column
          v-if="column.slot"
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :min-width="column.minWidth"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :sort-method="column.sortMethod"
          :sort-by="column.sortBy"
          :sort-orders="column.sortOrders"
          :resizable="column.resizable !== false"
          :show-overflow-tooltip="column.showTooltip"
        >
          <template #default="{ row, $index }">
            <slot 
              :name="column.slot" 
              :row="row" 
              :index="$index"
              :prop="column.prop"
              :column="column"
            >
              {{ formatCellValue(row, column) }}
            </slot>
          </template>
          
          <template #header="{ column: col, $index }">
            <slot 
              :name="`header-${column.prop}`" 
              :column="col" 
              :index="$index"
              :prop="column.prop"
            >
              {{ column.label }}
            </slot>
          </template>
        </el-table-column>
        
        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :min-width="column.minWidth"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :sort-method="column.sortMethod"
          :sort-by="column.sortBy"
          :sort-orders="column.sortOrders"
          :resizable="column.resizable !== false"
          :formatter="column.formatter"
          :show-overflow-tooltip="column.showTooltip"
        >
          <template #default="{ row, column: col, $index }">
            {{ formatCellValue(row, column) }}
          </template>
        </el-table-column>
      </template>
      
      <!-- 操作列 -->
      <el-table-column
        v-if="hasAction"
        :label="actionLabel"
        :width="actionWidth"
        :min-width="actionMinWidth"
        :fixed="actionFixed"
        :align="actionAlign"
      >
        <template #default="{ row, $index }">
          <slot 
            name="action" 
            :row="row" 
            :index="$index"
          />
        </template>
      </el-table-column>
      
      <!-- 自定义列插槽 -->
      <slot name="append" />
    </el-table>
    
    <!-- 底部区域 -->
    <div class="table-footer">
      <!-- 左侧统计信息 -->
      <div v-if="showSummary" class="footer-left">
        <slot name="summary">
          <span>共 {{ total }} 条记录</span>
          <span v-if="showSelection && selectedRows.length">
            已选择 {{ selectedRows.length }} 条
          </span>
        </slot>
      </div>
      
      <!-- 右侧分页器 -->
      <div v-if="showPagination" class="footer-right">
        <el-pagination
          v-model:current-page="currentPage"
          v-model:page-size="pageSize"
          :page-sizes="pageSizes"
          :total="total"
          :layout="paginationLayout"
          :background="paginationBackground"
          :disabled="paginationDisabled"
          :hide-on-single-page="hideOnSinglePage"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { Refresh, Grid, Setting, Rank } from '@element-plus/icons-vue'
import type { PropType } from 'vue'
import type { TableColumn } from './BaseTable'
import Sortable from 'sortablejs'

// Props 定义(继承 BaseTable 的 props 并扩展)
const props = defineProps({
  // ... 继承 BaseTable 的所有 props
  
  // 表格标题
  title: {
    type: String,
    default: ''
  },
  
  // 是否显示工具栏
  showToolbar: {
    type: Boolean,
    default: true
  },
  
  // 是否显示刷新按钮
  showRefresh: {
    type: Boolean,
    default: true
  },
  
  // 是否显示密度切换
  showDensity: {
    type: Boolean,
    default: true
  },
  
  // 是否显示列设置
  showColumnSetting: {
    type: Boolean,
    default: true
  },
  
  // 行唯一标识
  rowKey: {
    type: String,
    default: 'id'
  },
  
  // 是否显示展开行
  showExpand: {
    type: Boolean,
    default: false
  },
  
  // 展开行的 keys
  expandRowKeys: {
    type: Array as PropType<string[]>,
    default: () => []
  },
  
  // 默认排序
  defaultSort: {
    type: Object as PropType<{ prop: string; order: 'ascending' | 'descending' }>,
    default: null
  },
  
  // 合并单元格的方法
  spanMethod: {
    type: Function as PropType<({
      row,
      column,
      rowIndex,
      columnIndex
    }: {
      row: any
      column: any
      rowIndex: number
      columnIndex: number
    }) => number[] | { rowspan: number; colspan: number }>,
    default: null
  },
  
  // 是否显示汇总信息
  showSummary: {
    type: Boolean,
    default: true
  },
  
  // 选择列宽度
  selectionWidth: {
    type: [Number, String],
    default: 55
  },
  
  // 选择列是否固定
  selectionFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'left'
  },
  
  // 行是否可选
  selectable: {
    type: Function as PropType<(row: any, index: number) => boolean>,
    default: null
  },
  
  // 是否保留选择(数据更新后)
  reserveSelection: {
    type: Boolean,
    default: false
  },
  
  // 序号列宽度
  indexWidth: {
    type: [Number, String],
    default: 60
  },
  
  // 序号列标签
  indexLabel: {
    type: String,
    default: '序号'
  },
  
  // 序号列是否固定
  indexFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'left'
  },
  
  // 序号生成方法
  indexMethod: {
    type: Function as PropType<(index: number) => number>,
    default: (index: number) => index + 1
  },
  
  // 操作列标签
  actionLabel: {
    type: String,
    default: '操作'
  },
  
  // 操作列最小宽度
  actionMinWidth: {
    type: [Number, String],
    default: 120
  },
  
  // 操作列对齐方式
  actionAlign: {
    type: String as PropType<'left' | 'center' | 'right'>,
    default: 'center'
  },
  
  // 分页器背景
  paginationBackground: {
    type: Boolean,
    default: true
  },
  
  // 分页器禁用
  paginationDisabled: {
    type: Boolean,
    default: false
  },
  
  // 只有一页时是否隐藏分页器
  hideOnSinglePage: {
    type: Boolean,
    default: false
  },
  
  // 行类名
  rowClassName: {
    type: [String, Function] as PropType<string | (({ row, rowIndex }: { row: any; rowIndex: number }) => string)>,
    default: ''
  },
  
  // 单元格类名
  cellClassName: {
    type: [String, Function] as PropType<string | (({ row, column, rowIndex, columnIndex }: any) => string)>,
    default: ''
  }
})

// 事件定义
const emit = defineEmits([
  // ... 继承 BaseTable 的事件
  'refresh',
  'density-change',
  'column-change',
  'row-dblclick',
  'expand-change'
])

// 表格引用
const tableRef = ref()

// 内部状态
const tableSize = ref<'large' | 'default' | 'small'>(props.size as any)
const selectedRows = ref<any[]>([])
const checkedColumns = ref<string[]>([])
const allColumns = ref<TableColumn[]>([])

// 计算属性:是否有操作列
const hasAction = computed(() => !!props.$slots.action)

// 计算属性:可见列
const visibleColumns = computed(() => {
  if (!checkedColumns.value.length) return allColumns.value
  return allColumns.value.filter(col => checkedColumns.value.includes(col.prop))
})

// 计算属性:过滤后的数据(可用于前端搜索)
const filteredData = computed(() => {
  // 实现前端筛选逻辑
  return props.data
})

// 初始化列配置
onMounted(() => {
  allColumns.value = props.columns.filter(col => !col.hidden)
  checkedColumns.value = allColumns.value.map(col => col.prop)
  initDrag()
})

// 初始化拖拽排序
const initDrag = () => {
  nextTick(() => {
    const settingEl = document.querySelector('.column-setting .el-checkbox-group')
    if (!settingEl) return
    
    new Sortable(settingEl as HTMLElement, {
      animation: 150,
      handle: '.drag-icon',
      onEnd: (evt) => {
        const { oldIndex, newIndex } = evt
        if (oldIndex === newIndex) return
        
        // 重新排序列
        const newColumns = [...allColumns.value]
        const [movedColumn] = newColumns.splice(oldIndex!, 1)
        newColumns.splice(newIndex!, 0, movedColumn)
        allColumns.value = newColumns
        
        emit('column-change', newColumns)
      }
    })
  })
}

// 格式化单元格值
const formatCellValue = (row: any, column: TableColumn) => {
  if (column.formatter) {
    return column.formatter(row, column, row[column.prop], 0)
  }
  return row[column.prop]
}

// 列设置相关
const checkAll = computed({
  get: () => checkedColumns.value.length === allColumns.value.length,
  set: (val) => {
    checkedColumns.value = val ? allColumns.value.map(col => col.prop) : []
  }
})

const isIndeterminate = computed(() => {
  return checkedColumns.value.length > 0 && 
         checkedColumns.value.length < allColumns.value.length
})

const handleCheckAllChange = (val: boolean) => {
  checkedColumns.value = val ? allColumns.value.map(col => col.prop) : []
  emit('column-change', visibleColumns.value)
}

const handleCheckedChange = (value: string[]) => {
  emit('column-change', visibleColumns.value)
}

// 密度切换
const handleDensityChange = (size: string) => {
  tableSize.value = size as any
  emit('density-change', size)
}

// 刷新
const handleRefresh = () => {
  emit('refresh')
}

// 双击行
const handleRowDblClick = (row: any, column: any) => {
  emit('row-dblclick', { row, column })
}

// 展开行变化
const handleExpandChange = (row: any, expandedRows: any[]) => {
  emit('expand-change', { row, expandedRows })
}

// 暴露方法
defineExpose({
  // 清除选择
  clearSelection: () => {
    tableRef.value?.clearSelection()
    selectedRows.value = []
  },
  
  // 切换行选择
  toggleRowSelection: (row: any, selected?: boolean) => {
    tableRef.value?.toggleRowSelection(row, selected)
  },
  
  // 切换所有行选择
  toggleAllSelection: () => {
    tableRef.value?.toggleAllSelection()
  },
  
  // 设置某行展开状态
  toggleRowExpansion: (row: any, expanded?: boolean) => {
    tableRef.value?.toggleRowExpansion(row, expanded)
  },
  
  // 设置当前行
  setCurrentRow: (row: any) => {
    tableRef.value?.setCurrentRow(row)
  },
  
  // 清除排序
  clearSort: () => {
    tableRef.value?.clearSort()
  },
  
  // 清除筛选
  clearFilter: (columnKeys?: string[]) => {
    tableRef.value?.clearFilter(columnKeys)
  },
  
  // 重新布局
  doLayout: () => {
    tableRef.value?.doLayout()
  },
  
  // 滚动到某行
  scrollToRow: (row: any, offset?: number) => {
    // 实现滚动逻辑
  }
})
</script>

<style scoped lang="scss">
.pro-table {
  background-color: #fff;
  border-radius: 4px;
  padding: 16px;
  
  .table-toolbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
    
    .toolbar-left {
      .table-title {
        font-size: 16px;
        font-weight: 600;
        color: #303133;
      }
    }
    
    .toolbar-right {
      display: flex;
      gap: 8px;
    }
  }
  
  .table-footer {
    margin-top: 16px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    
    .footer-left {
      color: #909399;
      font-size: 14px;
      
      span {
        margin-right: 16px;
      }
    }
  }
  
  .column-setting {
    padding: 8px;
    
    .setting-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 8px;
    }
    
    .setting-item {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 4px 0;
      
      &:hover {
        background-color: #f5f7fa;
      }
      
      .drag-icon {
        cursor: move;
        color: #909399;
      }
    }
  }
}
</style>

五、使用示例

5.1 基础用法

<!-- views/UserList.vue -->
<template>
  <div class="user-list">
    <pro-table
      ref="tableRef"
      :data="userList"
      :columns="columns"
      :total="total"
      :loading="loading"
      :show-selection="true"
      :show-index="true"
      :page="page"
      :limit="limit"
      @page-change="handlePageChange"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @refresh="handleRefresh"
    >
      <!-- 自定义状态列 -->
      <template #status="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'info'">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </el-tag>
      </template>
      
      <!-- 自定义操作列 -->
      <template #action="{ row }">
        <el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
        <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
      </template>
    </pro-table>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ProTable from '@/components/ProTable.vue'
import type { TableColumn } from '@/components/BaseTable'
import { getUserList } from '@/api/user'

// 表格列配置
const columns: TableColumn[] = [
  {
    prop: 'name',
    label: '姓名',
    width: 120,
    sortable: true
  },
  {
    prop: 'age',
    label: '年龄',
    width: 80,
    align: 'center'
  },
  {
    prop: 'email',
    label: '邮箱',
    minWidth: 200,
    showTooltip: true
  },
  {
    prop: 'phone',
    label: '手机号',
    width: 150
  },
  {
    prop: 'status',
    label: '状态',
    width: 80,
    slot: 'status'  // 使用自定义插槽
  },
  {
    prop: 'createTime',
    label: '创建时间',
    width: 180,
    sortable: true,
    formatter: (row: any, column: any, value: string) => {
      return new Date(value).toLocaleString()
    }
  }
]

// 表格数据
const userList = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const limit = ref(20)

// 获取数据
const fetchData = async () => {
  loading.value = true
  try {
    const res = await getUserList({
      page: page.value,
      limit: limit.value
    })
    userList.value = res.list
    total.value = res.total
  } finally {
    loading.value = false
  }
}

// 分页变化
const handlePageChange = ({ page: newPage, limit: newLimit }: any) => {
  page.value = newPage
  limit.value = newLimit
  fetchData()
}

// 选择变化
const handleSelectionChange = (selection: any[]) => {
  console.log('选中:', selection)
}

// 排序变化
const handleSortChange = ({ prop, order }: any) => {
  console.log('排序:', prop, order)
  // 可以在这里处理排序逻辑
}

// 刷新
const handleRefresh = () => {
  fetchData()
}

// 编辑
const handleEdit = (row: any) => {
  console.log('编辑:', row)
}

// 删除
const handleDelete = (row: any) => {
  ElMessageBox.confirm('确认删除该用户吗?', '提示', {
    type: 'warning'
  }).then(() => {
    // 调用删除接口
    ElMessage.success('删除成功')
    fetchData()
  })
}

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

5.2 高级用法:动态列 + 展开行

<!-- views/OrderList.vue -->
<template>
  <pro-table
    :data="orderList"
    :columns="dynamicColumns"
    :total="total"
    :show-expand="true"
    :show-summary="true"
    :span-method="objectSpanMethod"
  >
    <!-- 展开行内容 -->
    <template #expand="{ row }">
      <div class="order-detail">
        <h4>订单详情</h4>
        <el-descriptions :column="3" border>
          <el-descriptions-item label="商品名称">{{ row.productName }}</el-descriptions-item>
          <el-descriptions-item label="单价">¥{{ row.price }}</el-descriptions-item>
          <el-descriptions-item label="数量">{{ row.quantity }}</el-descriptions-item>
          <el-descriptions-item label="总价">¥{{ row.totalPrice }}</el-descriptions-item>
          <el-descriptions-item label="下单时间">{{ row.orderTime }}</el-descriptions-item>
          <el-descriptions-item label="支付方式">{{ row.payMethod }}</el-descriptions-item>
        </el-descriptions>
      </div>
    </template>
    
    <!-- 自定义操作列 -->
    <template #action="{ row }">
      <el-button type="primary" link @click="viewOrder(row)">查看</el-button>
      <el-button type="success" link @click="processOrder(row)">处理</el-button>
    </template>
  </pro-table>
</template>

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

// 动态列配置(可以根据权限动态生成)
const columnsConfig = ref([
  { prop: 'orderNo', label: '订单号', width: 180, fixed: 'left' },
  { prop: 'customer', label: '客户', width: 120 },
  { prop: 'amount', label: '金额', width: 120, align: 'right' },
  { prop: 'status', label: '状态', width: 100 },
  { prop: 'payStatus', label: '支付状态', width: 100 },
  { prop: 'deliveryStatus', label: '发货状态', width: 100 },
  { prop: 'createTime', label: '创建时间', width: 180 },
  { prop: 'updateTime', label: '更新时间', width: 180 }
])

// 根据用户权限过滤列
const dynamicColumns = computed(() => {
  const userPermissions = ['orderNo', 'customer', 'amount', 'status']
  return columnsConfig.value.filter(col => userPermissions.includes(col.prop))
})

// 合并单元格
const objectSpanMethod = ({ row, column, rowIndex, columnIndex }: any) => {
  if (columnIndex === 0) {
    if (rowIndex % 2 === 0) {
      return {
        rowspan: 2,
        colspan: 1
      }
    } else {
      return {
        rowspan: 0,
        colspan: 0
      }
    }
  }
}
</script>

六、单元测试

// __tests__/ProTable.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ProTable from '@/components/ProTable.vue'

describe('ProTable.vue', () => {
  const mockColumns = [
    { prop: 'name', label: '姓名' },
    { prop: 'age', label: '年龄' }
  ]
  
  const mockData = [
    { name: '张三', age: 25 },
    { name: '李四', age: 30 }
  ]
  
  it('renders table correctly', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: mockColumns,
        total: 2
      }
    })
    
    expect(wrapper.find('.pro-table').exists()).toBe(true)
    expect(wrapper.findAll('.el-table__row').length).toBe(2)
  })
  
  it('emits page-change event when pagination changes', async () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: mockColumns,
        total: 100,
        showPagination: true
      }
    })
    
    // 模拟分页变化
    await wrapper.find('.el-pagination .btn-next').trigger('click')
    
    expect(wrapper.emitted('page-change')).toBeTruthy()
    expect(wrapper.emitted('page-change')?.[0]).toEqual([{ page: 2, limit: 20 }])
  })
  
  it('shows loading state', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: [],
        columns: mockColumns,
        loading: true
      }
    })
    
    expect(wrapper.find('.el-loading-mask').exists()).toBe(true)
  })
  
  it('renders custom slot content', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: [
          { prop: 'name', label: '姓名', slot: 'customName' }
        ]
      },
      slots: {
        customName: '<span class="custom-name">{{ row.name }}</span>'
      }
    })
    
    expect(wrapper.find('.custom-name').exists()).toBe(true)
  })
})

七、性能优化

7.1 虚拟滚动(大数据量)

<!-- 对于大量数据,可以使用虚拟滚动 -->
<template>
  <el-table
    v-loading="loading"
    :data="visibleData"
    :height="tableHeight"
    style="width: 100%"
    @scroll="handleScroll"
  >
    <!-- 列配置 -->
  </el-table>
</template>

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

const props = defineProps({
  data: {
    type: Array,
    default: () => []
  },
  rowHeight: {
    type: Number,
    default: 48
  },
  bufferSize: {
    type: Number,
    default: 10
  }
})

const scrollTop = ref(0)
const tableHeight = ref(600)

// 计算可见范围
const visibleCount = computed(() => Math.ceil(tableHeight.value / props.rowHeight))

const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / props.rowHeight) - props.bufferSize)
})

const endIndex = computed(() => {
  return Math.min(
    props.data.length,
    startIndex.value + visibleCount.value + props.bufferSize * 2
  )
})

const visibleData = computed(() => {
  return props.data.slice(startIndex.value, endIndex.value)
})

const handleScroll = (e) => {
  scrollTop.value = e.target.scrollTop
}
</script>

7.2 大数据量优化策略

// 1. 使用虚拟滚动
// 2. 按需渲染
// 3. 使用函数式组件
// 4. 避免不必要的响应式
// 5. 使用 computed 缓存计算结果
// 6. 列表项使用唯一的 key
// 7. 使用 v-once 处理静态内容

八、总结与最佳实践

8.1 组件设计要点

  1. Props 设计原则

    • 提供合理的默认值
    • 使用 TypeScript 类型定义
    • 保持 API 简洁但够用
  2. 插槽设计原则

    • 提供足够的自定义能力
    • 作用域插槽传递必要数据
    • 预留扩展位置
  3. 事件设计原则

    • 遵循 v-model 规范
    • 提供完整的事件体系
    • 事件命名清晰规范

8.2 使用建议

// 1. 合理配置列宽度
const columns = [
  { prop: 'name', label: '姓名', width: 120 }, // 固定宽度
  { prop: 'address', label: '地址', minWidth: 200 }, // 最小宽度
  { prop: 'description', label: '描述', width: 'auto' } // 自适应
]

// 2. 使用唯一 rowKey
<pro-table :data="list" row-key="id" />

// 3. 合理使用插槽
<template #status="{ row }">
  <Badge :status="row.status" />
</template>

// 4. 处理加载状态
<pro-table :loading="loading" :data="list" />

// 5. 处理空状态
<pro-table :data="[]" empty-text="暂无数据" />

8.3 扩展思考

  1. 如何支持表格导出?

    • 添加导出按钮和导出方法
    • 支持导出当前页或全部数据
    • 支持导出格式配置(CSV/Excel)
  2. 如何支持表格打印?

    • 添加打印样式
    • 隐藏操作列和按钮
    • 调整列宽适配打印
  3. 如何支持表格列拖动调整宽度?

    • 使用 resizable 属性
    • 保存用户调整后的宽度到 localStorage
  4. 如何支持表格状态持久化?

    • 保存列显示状态
    • 保存排序状态
    • 保存筛选状态

通过合理封装表格组件,可以极大提升开发效率,保证项目代码质量,这也是企业级前端开发的核心能力之一。

【vue hooks】useScreenOrientation-获取屏幕方向并支持低版本系统

2026年3月13日 11:03

为了解决vueuse的useScreenOrientation不支持低版本系统的问题,尤其是ios,索性自己写了个可兼容的版本。

代码

新建一个useScreenOrientation.js文件,代码如下

import { shallowRef } from "vue";
import { useEventListener, useSupported } from "@vueuse/core";

// TypeScript dropped the inline types for these types in 5.2
// We vendor them here to avoid the dependency

// export type OrientationType = 'portrait-primary' | 'portrait-secondary' | 'landscape-primary' | 'landscape-secondary'
// export type OrientationLockType = 'any' | 'natural' | 'landscape' | 'portrait' | 'portrait-primary' | 'portrait-secondary' | 'landscape-primary' | 'landscape-secondary'

// export interface ScreenOrientation extends EventTarget {
//   lock: (orientation: OrientationLockType) => Promise<void>
//   unlock: () => void
//   readonly type: OrientationType
//   readonly angle: number
//   addEventListener: (type: 'change', listener: (this: this, ev: Event) => any, useCapture?: boolean) => void
// }

// export interface UseScreenOrientationReturn extends Supportable {
//   orientation: ShallowRef<OrientationType | undefined>
//   angle: ShallowRef<number>
//   lockOrientation: (type: OrientationLockType) => Promise<void>
//   unlockOrientation: () => void
// }

/**
 * Reactive screen orientation
 *
 * @see https://vueuse.org/useScreenOrientation
 *
 * @__NO_SIDE_EFFECTS__
 */
export function useScreenOrientation(options = {}) {
  const isSupported = useSupported(
    () => window && "screen" in window && "orientation" in window.screen
  );

  const screenOrientation = isSupported.value ? window.screen.orientation : {};

  const orientation = shallowRef(screenOrientation.type);
  const angle = shallowRef(screenOrientation.angle || 0);

  const isIOS = /iphone|ipad|ipod/.test(
    navigator.userAgent.toLocaleLowerCase()
  );
  const getLandscape = () => {
    if (isIOS && Object.prototype.hasOwnProperty.call(window, "orientation")) {
      return Math.abs(window.orientation) === 90;
    }
    return window.innerHeight / window.innerWidth < 1;
  };

  if (isSupported.value) {
    // 这部分是原代码
    useEventListener(
      window,
      "orientationchange",
      () => {
        orientation.value = screenOrientation.type;
        angle.value = screenOrientation.angle;
      },
      { passive: true }
    );
  } else {
    // 新增兼容低版本
    const landscapeChange = () => {
      orientation.value = getLandscape()
        ? "landscape-primary"
        : "portrait-primary";
    };
    landscapeChange();
    useEventListener(
      window,
      "orientationchange",
      () => {
        landscapeChange();
      },
      { passive: true }
    );
  }

  const lockOrientation = type => {
    if (isSupported.value && typeof screenOrientation.lock === "function")
      return screenOrientation.lock(type);

    return Promise.reject(new Error("Not supported"));
  };

  const unlockOrientation = () => {
    if (isSupported.value && typeof screenOrientation.unlock === "function")
      screenOrientation.unlock();
  };

  return {
    isSupported,
    orientation,
    angle,
    lockOrientation,
    unlockOrientation
  };
}

解决 Cesium 网络卡顿!5 分钟加载天地图,内网也能流畅用,附完整代码

作者 李剑一
2026年3月13日 09:30

接上文,之前使用 Cesium.Ion 已经成功将地球效果展示出来了,飞入效果也非常不错。详细可以参考这篇文章:# 拿来就用!Vue3+Cesium 飞入效果封装,3D大屏多场景直接复用

但是仍然存在一个问题没解决, Cesium.Ion 的服务部署在外面,但是我们这边因为众所周知的原因网络受到一些限制。

image.png

虽然Cesium的服务是不被禁止访问的,但是访问速度和丢包率也是异常"喜人",所以之前还是打算在这个地方做一下优化。

解决思路

其实想要解决这个问题也非常的简单,将卫星地图(瓦片地图)换成我们自己的服务即可,访问咱们这边的服务是没啥问题的。

目前基本上是两个思路

  • 使用在线服务,主要是天地图、腾讯、高德等等几家
  • 使用离线服务,自己下载瓦片地图,自己搭建服务

这两种路线我都用了,可以说如果你有资源的话,那么我强烈建议你自己搭建离线地图服务,效果非常好。

最关键的是这套系统就能够实现离线部署了,在某些私有化场景下非常契合。

image.png

但是两个问题需要解决,资源存储空间

目前资源问题勉强能凑合解决一下,但是存储空间确实没有。毕竟地图下载下来也是真不小,另外目前没有离线部署的需求,所以考虑使用在线服务。

在线服务不推荐腾讯、高德几家,一来配置起来并不好整,我之前尝试腾讯的,鼓捣了半天仅仅弄好了个矢量图,卫星图花了一下午时间也没弄好。

切换到天地图,只用了5分钟就齐活了。

解决方案

使用天地图服务首先去天地图官网注册个账号,地址给大家放一下:www.tianditu.gov.cn/

首先进入控制台,选择开发管理下的开发者认证,认证一下个人开发者

只有这样才能够创建应用,生成tk

image.png

然后进入开发管理 > 应用管理 > 我的应用 > 创建新应用,简单填写一下必要信息,就能够创建一个新应用了。

复制一下应用密钥(tk)

实际代码

初始化加载 Cesium图层 的地方设置为 false

// 初始化 Cesium 地球
const initCesium = async () => {
    // 创建 Cesium 视图实例
    viewer.value = new Cesium.Viewer('cesiumContainer', {
        // 隐藏默认控件,简化界面
        timeline: false,
        animation: false,
        baseLayerPicker: false,
        geocoder: false,
        homeButton: false,
        infoBox: false,
        sceneModePicker: false,
        navigationHelpButton: false,
        // 开启深度检测,避免地形闪烁
        scene3DOnly: true,
        requestRenderMode: true,
        // 不加载默认的 Cesium Ion 影像图层
        baseLayer: false
    });

    // 隐藏 Cesium 版权信息(可选)
    viewer.value._cesiumWidget._creditContainer.style.display = 'none';

    // 等待 Cesium 完全加载完成
    await waitForCesiumFullyLoaded();
    
    // 添加天地图地图影像图层
    addTianDituImageryLayer();

    // 触发 cesiumReady 事件
    emit('cesiumReady', viewer.value);
}

天地图图层主要有两部分,一个是卫星影像底图,另一个是注记图层,当然如果不考虑名称,注记图层可以不添加。

/**
 * 添加天地图地图影像图层(卫星图 + 注记)
 */
const addTianDituImageryLayer = () => {
    if (!viewer.value) return;

    // 使用天地图卫星影像,tk (密钥)
    const webKey = '你的tk';

    // 天地图卫星影像底图
    const imgProvider = new Cesium.WebMapTileServiceImageryProvider({
        url: 'https://t{s}.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&TILEMATRIX={TileMatrix}&TILEROW={TileRow}&TILECOL={TileCol}&FORMAT=tiles&tk=' + webKey,
        layer: 'tdtImgBasicLayer',
        style: 'default',
        format: 'image/jpeg',
        tileMatrixSetID: 'GoogleMapsCompatible',
        maximumLevel: 18,
        minimumLevel: 1,
        subdomains: ['0', '1', '2', '3', '4', '5', '6', '7'],
        credit: new Cesium.Credit('天地图'),
        // 启用 CORS
        enablePickFeatures: false
    });

    // 添加卫星影像图层
    viewer.value.imageryLayers.addImageryProvider(imgProvider);

    // 天地图注记图层(地名标注)
    const ciaProvider = new Cesium.WebMapTileServiceImageryProvider({
        url: 'https://t{s}.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&TILEMATRIX={TileMatrix}&TILEROW={TileRow}&TILECOL={TileCol}&FORMAT=tiles&tk=' + webKey,
        layer: 'tdtAnnoLayer',
        style: 'default',
        format: 'image/jpeg',
        tileMatrixSetID: 'GoogleMapsCompatible',
        maximumLevel: 18,
        minimumLevel: 1,
        subdomains: ['0', '1', '2', '3', '4', '5', '6', '7'],
        credit: new Cesium.Credit('天地图注记'),
        // 启用 CORS
        enablePickFeatures: false
    });

    // 添加注记图层(叠加在影像之上)
    viewer.value.imageryLayers.addImageryProvider(ciaProvider);

    console.log('卫星影像加载完成!');
};

这里需要注意:记得将 enablePickFeatures 设为false,避免出现跨域问题。

总结

后续看是否有合适的项目,我会将离线地图的资源和创建方式分享给大家。

如果你的资源足够强,甚至能看到非常精细的卫星图像。

离线地图的玩法也远比在线地图要多得多,你甚至可以DIY某个地方的卫星图像,做出现实版的我的世界

另外需要注意,天地图的API调用是有限制的,详情可以参考下图。

20260309-限额.png

React vs Vue 2026年怎么选?9年前端的真实建议

2026年3月13日 08:08

标签:React、Vue、前端、技术选型

这是前端圈永远吵不完的话题——React和Vue到底选哪个。

我做了9年前端,React和Vue都在生产项目中深度使用过。今天不参与阵营对立,只说实际情况,帮你做决策。

先说结论

没有绝对的好坏,只有适不适合。 但如果你非要我选一个——

  • 找工作为主 → 看你目标城市/公司的技术栈,哪个岗位多选哪个
  • 个人项目/创业 → Vue(上手快,生态齐全,AI工具生成Vue代码质量更高)
  • 大厂/大型项目 → React(大厂用的多,生态更灵活)
  • 新手入门 → Vue 3(学习曲线更平缓)

下面是详细分析。

1. 学习曲线

Vue 3:模板语法直觉性强,Composition API + <script setup> 写起来很舒服。从零到能写业务组件大概需要1-2周。

React:JSX需要适应,Hooks的心智模型比较抽象(useEffect依赖数组、闭包陷阱)。从零到能写业务组件大概3-4周。

// Vue 3 组件
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const increment = () => count.value++
</script>
<template>
  <button @click="increment">{{ count }}</button>
</template>

// React 组件
import { useState } from 'react'
function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

Vue的单文件组件(SFC)把模板、逻辑、样式放在一起,结构清晰。React的JSX把HTML写在JS里,灵活但对新手不太友好。

这一轮:Vue上手更快,React上限更灵活。

2. 生态对比

维度 Vue React
UI库 Element Plus、Ant Design Vue、Naive UI Ant Design、MUI、Chakra UI、shadcn/ui
状态管理 Pinia(官方推荐,简单够用) Zustand/Jotai(轻量)/ Redux Toolkit(复杂)
路由 Vue Router(官方) React Router / TanStack Router
SSR Nuxt 3(成熟稳定) Next.js(生态最强)
移动端 Uni-app / Taro React Native
桌面端 Electron + Vue Electron + React
AI工具支持 Cursor/Claude Code均良好 Cursor/Claude Code/v0均良好,v0原生React

React的生态更大、选择更多。Vue的生态更统一、选择成本更低。

这一轮:React生态广度胜,Vue生态统一性胜。

3. 就业市场

这才是很多人真正关心的。

2026年的实际情况是:

  • 一线城市大厂(北上广深杭):React占比60%+,Vue占30%左右
  • 二三线城市/中小公司:Vue占比60%+,因为上手快、招人容易
  • 外企/海外远程:React为主
  • 自由职业/外包:Vue更多,因为国内中小企业项目Vue占主流

建议:如果你已经在职,公司用什么你学什么。如果你在选方向,先看你目标城市/公司的招聘信息,哪个岗位多就学哪个。

4. 和AI编程工具的配合

这是2026年新增的重要维度。

我在用Cursor写代码时,Vue和React的AI生成质量对比:

  • 组件生成:两者差不多,Vue的SFC结构让AI更容易理解组件边界
  • 状态管理:Pinia代码比Redux简单得多,AI生成正确率更高
  • 类型推断:TypeScript + Vue 3在Cursor中的类型支持已经和React持平
  • v0工具:只支持React + Tailwind,Vue开发者需要自己转换

总体来说,AI工具对两者的支持都很好。Vue因为约定更统一,AI生成的代码一致性更好。

5. 我的真实使用感受

作为两个框架都深度使用过的人,说说我的主观感受:

Vue让我感觉"舒服"——官方提供的方案够用,不需要纠结选什么状态管理、选什么路由。Pinia + Vue Router + Vite,闭眼选就行。写业务代码效率极高。

React让我感觉"自由"——想怎么组织代码就怎么组织,但选择太多有时候也是负担。一个状态管理就有Redux、MobX、Zustand、Jotai、Recoil、Valtio六七个选择,每个都有人推荐。

如果你是"我不想纠结,给我最优方案就行"的人——选Vue。

如果你是"我喜欢自己搭配,享受灵活性"的人——选React。

最终建议

不要两个都学(至少不要同时学)。先精通一个,用它接项目、找工作、做产品。等你在一个框架上有了深度理解之后,切换到另一个只需要1-2周。

框架只是工具,真正重要的是你理解组件化思维、状态管理、性能优化、工程化——这些在任何框架中都通用。

评论区说说你目前用React还是Vue?为什么选它?


我是前端老兵AI,9年+前端工程师,React和Vue都在生产项目中使用过

📦 加微信lxxs1203,备注"掘金",领取《前端+AI编程实战干货包》

🎬 B站搜索:前端老兵AI

📱 公众号搜索「前端老兵之AI」,持续更新深度技术文章

❌
❌