阅读视图

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

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

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

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

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


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

典型“病状”自查:

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

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


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

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

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

image.png


三、解决方案核心:nvm(Node Version Manager)

nvm 是什么?
一个让你在电脑上同时安装、共存多个 Node.js 版本,并能在终端里一键切换的工具。

🪟 Windows 用户指南:nvm-windows

1️⃣ 安装前的准备工作(非常重要!)

安装 nvm-windows 之前,务必彻底卸载电脑上原有的 Node.js,避免冲突:

  • “控制面板” -> “程序和功能” -> 卸载 Node.js

  • 手动删除以下残留文件夹(如存在):

    text

    C:\Program Files\nodejs
    C:\Program Files (x86)\nodejs
    C:\Users<你的用户名>\AppData\Roaming\npm
    C:\Users<你的用户名>\AppData\Roaming\npm-cache
    
  • 检查系统的 PATH 环境变量,删除所有与 Node.js 或 npm 相关的路径

2️⃣ 安装 nvm-windows
  1. 访问 nvm-windows 发布页,下载最新版 nvm-setup.zip
  2. 解压后,以管理员身份运行 nvm-setup.exe
  3. 按向导安装,路径建议保持默认(避免权限问题)。
  4. 安装完成后,重启命令行工具(CMD 或 PowerShell)。
3️⃣ 下载加速(国内用户强烈推荐)

在 nvm 安装目录(默认 C:\Users<你的用户名>\AppData\Roaming\nvm)下,找到 settings.txt,末尾添加:

text

node_mirror: https://npmmirror.com/mirrors/node/
npm_mirror: https://npmmirror.com/mirrors/npm/

这样可以大幅提升国内下载 Node.js 的速度。

🍎 macOS / Linux 用户指南:标准版 nvm

在终端中执行:

bash

# 使用 curl
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash

# 或使用 wget
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash

安装脚本会自动将 nvm 加入到你的 shell 配置文件(~/.bashrc~/.zshrc 等)。安装完成后,重启终端或运行 source ~/.zshrc(根据你的 shell 选择)使其生效。


四、一图看懂 nvm 核心操作

我要做什么 命令示例
查看能装哪些 Node 版本 Windows: nvm list available Mac/Linux: nvm ls-remote
安装某个具体版本 nvm install 16.20.0
安装最新的 LTS 版本 nvm install --lts
看我电脑里已有哪些版本 nvm list
在当前终端切换到某个版本 nvm use 16.20.0
设置默认(新打开终端)版本 nvm alias default 16.20.0
删除某个版本 nvm uninstall 16.20.0
查看当前使用版本 node -v

⚠️ Windows 用户特别注意:执行 nvm use 切换版本时,建议以管理员身份打开命令行,否则可能因权限不足而切换失败。


五、终极奥义:自动化项目版本切换(.nvmrc)

再也不用手动记住每个项目用的 Node 版本。

操作步骤

  1. 在项目根目录下,创建一个名叫  .nvmrc 的文件(注意开头有个点)。

  2. 文件内容只需一行,比如:16.20.0(或者 lts/gallium,等别名)。

  3. 当你要进入该项目工作时,在项目根目录执行:

    bash

    nvm use
    

    nvm 会自动读取 .nvmrc 中指定的版本并切换过去。

更高级:自动切换(可选)

如果你希望每次 cd 进项目目录时自动切换,可以借助 avn 或 zsh-nvm 插件。但个人建议:手动执行 nvm use 已经足够简洁,且避免了误切换。


Vue前端SEO优化全攻略(实操落地版,新手也能上手)

Vue作为主流前端框架,其默认的客户端渲染(CSR)模式存在天然SEO短板——SPA页面初始加载仅返回空骨架HTML,核心内容通过JavaScript动态渲染,搜索引擎爬虫可能无法等待JS执行完毕,导致页面内容无法被正常抓取、索引,最终影响网站曝光和排名。

Vue前端SEO优化的核心逻辑的是:让搜索引擎爬虫能轻松抓取页面核心内容、识别页面层级、明确页面价值,本质是解决“爬虫可见性”和“内容可识别性”两大问题。以下方案从基础到进阶,覆盖所有高频优化场景,附具体代码和避坑细节,Vue2/Vue3通用,可直接复制落地。

一、核心优化:解决SPA渲染短板(爬虫抓取核心)

Vue SEO的最大痛点的是“动态内容无法被爬虫抓取”,核心解决方案有3种,根据项目规模和需求选择,优先推荐“预渲染”(低成本、易落地),动态内容多的场景选择“SSR”,快速落地可选择“静态站点生成”。

1. 预渲染(Prerendering):低成本首选,适配静态内容场景

核心逻辑:在项目构建阶段,提前渲染指定路由的静态HTML文件(包含完整内容),部署后用户和爬虫访问时,直接返回渲染好的静态页面,无需等待客户端JS执行,完美解决SPA初始内容为空的问题。

适配场景:内容相对固定的页面(官网、博客详情、产品介绍页),无需服务器额外部署,静态托管即可,开发成本最低。

实操步骤(Vue3+Vite适配):

  1. 安装预渲染插件:pnpm add -D @prerenderer/rollup-plugin(Vite项目);Vue2+Webpack项目可使用prerender-spa-plugin
  2. 配置vite.config.js,指定需要预渲染的路由: import { defineConfig } from 'vite' `` import vue from '@vitejs/plugin-vue' `` import prerender from '@prerenderer/rollup-plugin' ```` export default defineConfig({ `` plugins: [ `` vue(), `` // 预渲染配置 `` prerender({ `` routes: ['/', '/about', '/product', '/contact'], // 需要预渲染的路由(必填) `` renderer: '@prerenderer/renderer-puppeteer' // 渲染器,无需额外配置 `` }) `` ] ``})
  3. 执行npm run build,构建后dist目录会生成每个路由对应的静态HTML文件(如/about/index.html),直接部署即可;
  4. 避坑点:预渲染仅适用于内容固定的页面,动态内容(如实时数据、用户中心)无法预渲染,需结合其他方案;路由较多时,会增加构建时间。

2. 服务端渲染(SSR):动态内容首选,适配高需求场景

核心逻辑:用户/爬虫发起请求时,服务器先执行Vue代码,渲染出完整的HTML(包含动态内容),再将HTML返回给客户端,爬虫可直接抓取完整内容,同时能提升首屏加载速度,是动态内容(电商商品页、资讯列表)的最优解。

适配场景:动态内容多、对SEO和首屏速度要求高的项目(电商、资讯平台),需额外部署Node.js服务器,开发和运维成本较高。

实操方案(两种选择,优先推荐Nuxt.js):

  • 方案1:使用Nuxt.js(Vue官方推荐,简化SSR配置)

    • 创建Nuxt项目(Vue3):npx nuxi init my-nuxt-seo
    • Nuxt自动实现SSR,页面组件中可通过asyncDatafetch获取服务端数据,确保渲染的HTML包含动态内容: <script setup> `` // 服务端获取数据,渲染到HTML中,爬虫可直接抓取 `` const { data } = await useAsyncData('productList', () => { `` return fetch('/api/product').then(res => res.json()) `` }) ``</script>
    • 部署:需部署到支持Node.js的服务器(如阿里云ECS、Vercel),Nuxt提供一键部署方案,降低运维成本。
  • 方案2:自定义SSR(Vue2/Vue3通用,灵活度高)

    • 基于Express+vue-server-renderer实现,核心是创建服务端渲染入口,将Vue组件渲染为HTML字符串,返回给客户端;
    • 注意:需区分客户端和服务端环境,避免在服务端使用window、document等浏览器API,否则会报错。

补充:SSR的核心优势是支持动态内容抓取,但需注意服务器负载,可通过CDN缓存优化,减少服务器压力。

3. 静态站点生成(SSG):折中方案,兼顾成本和动态性

核心逻辑:在构建阶段生成所有页面的静态HTML(类似预渲染),但支持动态数据注入,构建后可静态托管,同时能通过增量构建更新内容,适配内容更新频率较低的动态场景(如每周更新的资讯、商品页)。

实操方案(Vue3+ViteSSG):

  1. 安装插件:pnpm add -D vite-ssg
  2. 改造入口文件main.ts(替换createApp,交给ViteSSG接管): import { ViteSSG } from 'vite-ssg' `` import App from './App.vue' `` import { routes } from './router' // 导出路由数组,而非router实例 ```` // 核心改造:ViteSSG生成静态站点 `` export const createApp = ViteSSG( `` App, `` { routes, base: import.meta.env.BASE_URL }, `` ({ app, router }) => { `` // 注册插件(如Pinia、VueMeta) `` } ``)
  3. 路由配置改造:需导出routes数组,且必须使用History模式,避免Hash模式破坏静态页面结构;
  4. 优势:无需部署Node.js服务器,静态托管即可,支持动态数据注入,构建后页面加载速度快,爬虫抓取友好。

二、基础优化:元信息(Meta)配置(爬虫识别核心)

搜索引擎爬虫抓取页面时,首先读取页面的元信息(Title、Description、Keywords等),用于判断页面主题和价值,是SEO优化的基础,必须每个页面配置独立的元信息,避免全局统一配置导致的权重分散。

1. 核心插件:vue-meta(Vue2/Vue3通用)

用于在组件级别管理元信息,支持动态设置Title、Meta标签、OG标签(用于社交媒体分享),无需手动操作DOM,适配SPA、SSR、SSG所有场景。

实操步骤:

  1. 安装插件:npm install vue-meta --save
  2. 全局注册(main.ts): import { createApp } from 'vue' `` import App from './App.vue' `` import VueMeta from 'vue-meta' ```` const app = createApp(App) `` app.use(VueMeta, { `` refreshOnceOnNavigation: true // 路由切换时刷新元信息 `` }) ``app.mount('#app')
  3. 组件中配置(每个页面独立配置): <script setup> `` // Vue3组合式API配置 `` useMeta({ `` title: 'Vue SEO优化指南 | 新手也能落地的实操方案', // 页面标题(核心,包含关键词) `` htmlAttrs: { lang: 'zh-CN' }, // 页面语言,帮助爬虫识别 `` meta: [ `` { name: 'description', content: '本文详细讲解Vue前端SEO优化方法,包含预渲染、SSR、元信息配置等实操技巧,适合新手学习,可直接复制落地。' }, // 页面描述(吸引点击,包含核心关键词) `` { name: 'keywords', content: 'Vue SEO, Vue前端SEO, Vue预渲染, Vue SSR' }, // 核心关键词(3-5个为宜,避免堆砌) `` // OG标签(优化社交媒体分享,提升曝光) `` { property: 'og:title', content: 'Vue SEO优化指南' }, `` { property: 'og:description', content: '新手也能落地的Vue前端SEO实操方案' }, `` { property: 'og:type', content: 'article' } `` ] `` }) ``</script>

2. 路由级元信息配置(统一管理,避免遗漏)

通过Vue Router的meta配置,统一管理所有页面的元信息,结合全局导航守卫,实现路由切换时自动更新元信息,适合页面较多的项目。

// router/index.ts(Vue3)
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    component: () => import('../views/Home.vue'),
    meta: {
      title: '首页 | Vue SEO优化实战',
      metaTags: [
        { name: 'description', content: '首页:专注Vue前端SEO优化,分享可落地的实操技巧' },
        { name: 'keywords', content: 'Vue SEO, 前端SEO, Vue优化' }
      ]
    }
  },
  {
    path: '/product/:id',
    component: () => import('../views/Product.vue'),
    meta: {
      title: '产品详情 | Vue SEO优化实战',
      metaTags: [
        { name: 'description', content: '产品详情页,展示Vue SEO相关工具和方案' },
        { name: 'keywords', content: 'Vue产品, SEO工具, Vue优化方案' }
      ]
    }
  }
]

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

// 全局导航守卫:路由切换时更新元信息
router.beforeEach((to, from, next) => {
  // 更新页面标题
  document.title = to.meta.title || 'Vue SEO优化指南'
  
  // 移除已存在的meta标签,避免重复
  const existingTags = document.querySelectorAll('meta[name^="vue-meta-"]')
  existingTags.forEach(tag => tag.parentNode.removeChild(tag))
  
  // 添加新的meta标签
  if (to.meta.metaTags) {
    to.meta.metaTags.forEach(tag => {
      const metaTag = document.createElement('meta')
      metaTag.setAttribute('name', tag.name)
      metaTag.setAttribute('content', tag.content)
      metaTag.setAttribute('vue-meta', '1')
      document.head.appendChild(metaTag)
    })
  }
  
  next()
})

export default router

3. 避坑点

  • Title:每个页面独立,包含1-2个核心关键词,长度控制在30字以内,避免堆砌关键词;
  • Description:简洁明了,包含核心关键词,长度控制在120字以内,吸引用户点击,避免和其他页面重复;
  • Keywords:3-5个为宜,贴合页面内容,避免堆砌(如“Vue,SEO,VueSEO,前端优化,SEO优化”);
  • OG标签:必须配置,优化微信、微博等社交媒体分享时的预览效果,提升页面曝光率。

三、内容优化:让爬虫“读懂”页面内容

即使解决了渲染问题,若页面内容杂乱、结构不清晰,爬虫仍无法识别核心价值,需优化内容结构和标签使用,提升页面权重。

1. 语义化标签使用(核心)

Vue模板中优先使用语义化标签,替代div嵌套,帮助爬虫识别页面层级和内容类型,提升页面可读性。

<!-- 推荐:语义化标签,清晰区分页面结构 --&gt;
&lt;header&gt;
  &lt;h1&gt;Vue SEO优化指南&lt;/h1&gt; <!-- 每个页面只有1个h1,作为页面核心标题 -->
  <nav><!-- 导航栏 -->
    <a href="/" rel="canonical">首页</a>
    <a href="/about">关于我们</a>
  </nav>
</header&gt;
&lt;main&gt;<!-- 页面核心内容 -->
  <section><!-- 内容区块 -->
    <h2>一、核心优化方案</h2><!-- h2-h6层级递减,不跳级 -->
    <p>Vue SEO的核心是解决爬虫抓取问题,主要有3种方案...</p>
  </section&gt;
&lt;/main&gt;
&lt;footer&gt;<!-- 页脚 -->
  <p>© 2026 Vue SEO优化指南 版权所有</p>
</footer>

关键要点:

  • 每个页面只有1个h1标签,作为页面核心标题,包含核心关键词;
  • h2-h6标签层级递减,不跳级(如h1之后是h2,h2之后是h3),清晰区分内容层级;
  • 使用header、main、nav、section、footer等语义化标签,替代div,帮助爬虫识别页面结构。

2. 动态内容优化(爬虫可识别)

对于SPA中的动态内容(如列表、详情),除了使用SSR/SSG/预渲染,还需注意:

  • 避免使用v-if隐藏核心内容:爬虫可能无法识别v-if控制的内容,若必须隐藏,可使用v-show(通过CSS隐藏,内容仍在HTML中);
  • 图片、视频添加alt属性:图片需添加alt属性(描述图片内容,包含关键词),视频添加title属性,帮助爬虫识别多媒体内容; <!-- 正确示例:图片添加alt属性 --> ``<img src="/vue-seo.jpg" alt="Vue前端SEO优化实操步骤" />
  • 结构化数据标记(Schema.org):给核心内容(如文章、产品、资讯)添加结构化数据,帮助搜索引擎理解内容类型,提升搜索排名(如电商商品可标记价格、评分,文章可标记作者、发布时间): <script setup> `` useMeta({ `` script: [ `` { `` type: 'application/ld+json', `` json: { `` "@context": "https://schema.org", `` "@type": "Article", `` "name": "Vue前端SEO优化全攻略", `` "description": "新手也能落地的Vue SEO实操方案", `` "author": { "@type": "Person", "name": "前端开发者" }, `` "datePublished": "2026-04-23" `` } `` } `` ] `` }) ``</script>

3. 内部链接优化

  • 页面之间添加合理的内部链接(如首页链接到产品页、文章页),帮助爬虫抓取更多页面,提升网站整体权重;
  • 避免使用空链接、死链接,链接文本需贴合目标页面内容(如“查看Vue预渲染教程”,而非“点击这里”);
  • 使用rel="canonical"标签,避免页面重复(如同一内容有多个URL,指定规范URL),防止权重分散: <a href="/product" rel="canonical">产品列表</a>

四、性能优化:提升页面加载速度(辅助SEO)

搜索引擎优先收录加载速度快的页面,Vue项目的性能优化不仅能提升用户体验,还能间接提升SEO排名,核心优化点如下:

1. 资源优化

  • 图片优化:压缩图片(使用tinypng等工具),使用WebP格式,懒加载(避免首屏加载过多图片),Vue中可使用vue-lazyload插件: // 安装:npm install vue-lazyload --save `` // 全局注册 `` import VueLazyload from 'vue-lazyload' `` app.use(VueLazyload, { `` loading: '/loading.png', // 加载中占位图 `` error: '/error.png' // 加载失败占位图 `` }) `` // 页面使用 ``<img v-lazy="imgSrc" alt="Vue SEO优化" />
  • JS/CSS优化:开启Gzip压缩(需服务器配置),拆分代码(路由懒加载),减少首屏加载体积: // 路由懒加载(Vue Router) `` const routes = [ `` { `` path: '/about', `` component: () => import('../views/About.vue') // 懒加载,按需加载组件 `` } ``]
  • 静态资源CDN托管:将图片、JS、CSS等静态资源部署到CDN(如阿里云CDN),提升资源加载速度,减轻服务器压力。

2. 首屏加载优化

  • 减少首屏JS体积:移除无用代码,按需引入第三方插件(如Element Plus可按需引入组件);
  • 预加载核心资源:使用<link rel="preload">预加载首屏必需的资源(如核心JS、CSS);
  • 优化webpack/vite配置:压缩代码、移除注释,减少构建后文件体积: // vue.config.js(Vue2+Webpack) `` module.exports = { `` configureWebpack: config => { `` config.plugin('html').tap(args => { `` args[0].minify = { `` removeComments: true, `` collapseWhitespace: true, // 压缩HTML `` removeAttributeQuotes: true `` } `` return args `` }) `` } ``}

五、其他关键优化(避坑必看)

1. 路由优化(History模式)

Vue Router默认使用Hash模式(URL带#),#后面的内容无法被爬虫识别,需切换为History模式,并配置服务器,避免404错误。

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
  history: createWebHistory(), // 切换为History模式
  routes
})

服务器配置(以Nginx为例):

server {
  listen 80;
  server_name your-domain.com;
  root /usr/share/nginx/html; # 部署目录
  
  # 解决History模式404问题
  location / {
    try_files $uri $uri/ /index.html;
  }
}

2. 避免SEO黑名单操作

  • 禁止隐藏关键词(如文字颜色和背景色一致)、堆砌关键词,会被搜索引擎判定为作弊,降低排名;
  • 禁止使用iframe嵌套核心内容,爬虫可能无法抓取iframe内的内容;
  • 禁止动态生成的内容完全依赖JS(如无SSR/预渲染,仅通过JS渲染核心内容),会导致爬虫无法抓取。

3. 配置robots.txt和sitemap.xml

  • robots.txt:放在网站根目录,指定爬虫可抓取和不可抓取的页面,避免爬虫抓取无用页面(如后台管理页): # robots.txt `` User-agent: * # 所有爬虫 `` Allow: / # 允许抓取所有页面 `` Disallow: /admin/ # 禁止抓取后台页面 ``Disallow: /api/ # 禁止抓取接口页面
  • sitemap.xml:生成网站地图,列出所有需要被抓取的页面,提交给百度、谷歌等搜索引擎,帮助爬虫快速抓取所有页面,提升收录效率。

六、优化效果验证(必做步骤)

优化完成后,需验证优化效果,确保爬虫能正常抓取页面内容,核心验证工具和步骤:

  1. 查看页面源码:右键“查看页面源代码”,确认核心内容、元信息、语义化标签是否存在(非空骨架);
  2. 百度搜索资源平台:提交网站、sitemap.xml,使用“URL提交”功能,验证页面是否能被收录;
  3. Google Search Console:验证页面收录情况,查看爬虫抓取错误,及时调整优化方案;
  4. SEO检测工具:使用爱站、站长工具等,检测页面元信息、关键词密度、加载速度等,优化不足的地方。

七、总结(实操优先级)

Vue前端SEO优化的实操优先级:渲染方式优化(预渲染/SSR/SSG)→ 元信息配置 → 内容语义化 → 性能优化 → 路由/robots配置

新手建议:先从预渲染+元信息配置入手(低成本、易落地),解决核心的爬虫抓取问题;若项目有动态内容,再升级为SSR/SSG;最后优化内容和性能,提升页面权重和排名。

核心原则:SEO优化是长期过程,需持续更新内容、监控抓取情况、调整优化方案,才能逐步提升网站曝光和排名。

从零搭建音视频通话太痛苦?这个 Vue3 CallKit 让你 5 分钟搞定 1v1 + 群聊通话

📌 声明:本篇文章基于 Easemob Chat CallKit Vue3 开源项目由 AI 辅助生成。

如果你正在 Vue3 项目中集成音视频通话功能,却被信令协议、状态管理、多端同步、UI 布局搞得焦头烂额——Easemob Chat CallKit Vue3 可能是你一直在找的答案。基于环信 IM + 声网 RTC,内置完整的呼叫、接听、挂断、群聊网格、媒体控制能力,真正的开箱即用


😫 我们先聊聊:自建音视频通话,到底难在哪?

做过实时音视频的同学应该都有体会,这玩意儿看起来只是"接个 SDK",真动手才发现坑一个接一个:

1. 信令层 = 自己造轮子

IM 消息和 RTC 是完全两套系统。呼叫、接听、拒绝、挂断、超时、占线……每一个动作都要自己定义信令格式、处理发送失败、重连补偿、离线消息过滤。稍不留神,两端状态就对不上——我这边显示"通话中",对方那边已经挂了。

2. 状态管理是灾难

单聊还勉强能搞个 isCalling flag,群聊直接懵圈:谁加入了、谁拒绝了、谁掉线了、谁在响铃中、视频轨道谁发布了……状态一多,Vue 的响应式系统开始疯狂重渲染,内存泄漏和竞态条件接踵而至。

3. UI 实现成本被严重低估

视频通话的 UI 不是简单放个 <video> 标签。单聊要有悬浮窗、最小化、拖拽、画中画;群聊要有九宫格、主视频模式、说话者高亮、成员管理。这些交互写起来没半个月下不来。

4. 单聊和群聊像是两个世界

单聊是二元状态机(呼叫方 ↔ 被叫方),群聊是分布式参与者集合。两者的信令协议、状态模型、UI 布局完全不同,很多团队最后不得不维护两套代码。


🎯 CallKit 解决什么问题?一句话:把上面这些坑全部填平

Easemob Chat CallKit Vue3 是环信官方推出的音视频通话 UI 组件库,基于 Vue 3 + 环信 IM SDK + 声网 RTC SDK,把信令、状态、UI 全部封装好,开发者只需要关心三件事:

  1. 我已经登录了环信 IM(你本来就要做聊天功能对吧?)
  2. 我要呼叫谁(传一个 userId 或 groupId)
  3. 我要监听什么事件(通话结束记个时长、写条消息)

其他的一切——信令收发、RTC 频道管理、视频渲染、邀请弹窗、通话计时、静音/摄像头切换——全部内置


🚀 5 分钟接入:从安装到打通第一通电话

安装依赖

# 安装 CallKit(以及你项目里已有的 IM 和 RTC SDK)
pnpm add easemob-chat-callkit-vue3 easemob-websdk agora-rtc-sdk-ng

Step 1:注册插件

// main.ts
import { createApp } from 'vue'
import EasemobChatCallKit from 'easemob-chat-callkit-vue3'
import 'easemob-chat-callkit-vue3/style.css'
import App from './App.vue'

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

Step 2:在根组件放置 Provider + 通话组件

<template>
  <EasemobChatCallKitProvider :chat-client="chatClient" :init-config="{ logLevel: 2 }">
    <!-- 你的应用内容 -->
    <router-view />

    <!-- 被叫邀请弹窗(自动弹出,无需 v-if) -->
    <InvitationNotification />

    <!-- 单人通话组件(自动显隐) -->
    <EasemobChatSingleCall />

    <!-- 群组通话组件(自动显隐) -->
    <EasemobChatMultiCall :group-id="currentGroupId" />
  </EasemobChatCallKitProvider>
</template>

<script setup lang="ts">
import {
  EasemobChatCallKitProvider,
  InvitationNotification,
  EasemobChatSingleCall,
  EasemobChatMultiCall,
} from 'easemob-chat-callkit-vue3'

// 你已经有的环信 IM 实例
const chatClient = /* 你的 easemob-websdk Connection */
const currentGroupId = /* 当前群组 ID */
</script>

Step 3:发起通话

<script setup lang="ts">
import { useCallKit } from 'easemob-chat-callkit-vue3'

const { call, groupCall, hangup, accept, reject } = useCallKit()

// ── 发起 1v1 视频通话 ──
await call({
  targetId: 'user123',
  type: 'video',
  userInfo: {
    nickname: '张三',
    avatarURL: 'https://example.com/avatar.png'
  }
})

// ── 发起群组视频会议 ──
await groupCall({
  groupId: 'group001',
  members: ['user1', 'user2', 'user3'],
  type: 'video',
  groupName: '产品周会',
})

// ── 挂断 ──
await hangup()
</script>

就这三步。 不需要手动创建 RTC 频道,不需要处理信令消息,不需要写 v-if 控制组件显示隐藏。EasemobChatSingleCall 会根据内部状态自动出现和消失。


🎧 监听通话生命周期,集成到你的业务

通话结束后想记个时长?收到来电想响个铃?用 useCallKitEvents() 一站式订阅:

import { useCallKitEvents, HANGUP_REASON } from 'easemob-chat-callkit-vue3'
import { onUnmounted } from 'vue'

const {
  onCallStarted,
  onCallEnded,
  onIncomingCall,
  onCallRefused,
  getCallRecord,   // ← 自动生成标准化通话记录
} = useCallKitEvents()

// 通话接通
const unbind1 = onCallStarted((e) => {
  console.log('通话开始', e.callId, '频道:', e.channel)
})

// 通话结束 → 自动获取通话记录,插入消息列表
const unbind2 = onCallEnded((e) => {
  const sec = Math.round(e.duration / 1000)
  console.log('通话结束,时长:', sec, '秒,原因:', e.reason)

  // 直接拿到标准化记录,无需自己拼凑字段
  const record = getCallRecord()
  // record = { callId, conversationId, chatType, from, to, status, duration, timestamp, endedBy }
  // 可以直接插入本地消息或发送 custom 消息
})

// 组件卸载时解绑,防止内存泄漏
onUnmounted(() => { unbind1(); unbind2() })

所有事件都携带 conversationIdisLocallocalUserRole 字段——你再也不用自己推断"这是单聊还是群聊""这是本端触发还是对端触发"。


🏗️ 设计思路:为什么 CallKit 能做得这么薄?

很多开发者会担心:"封装得这么彻底,灵活性会不会很差?" 不会。CallKit 的架构设计核心就一句话:"该隔离的隔离,该共享的共享"

四层架构模型

┌─────────────────────────────────────────────┐
│              UI 层(完全隔离)                │
│   SingleCall (1v1 悬浮窗) │ GroupCallShell   │
│   (自动显隐/拖拽/画中画)   │ (九宫格/主视频)   │
└─────────────────────────────────────────────┘
                     ↕
┌─────────────────────────────────────────────┐
│           状态层(领域隔离 + 共享)            │
│  SingleCallStore  │  GroupCallStore          │
│  (二元状态机)      │  (分布式参与者集合)       │
│                   ↕                          │
│         GlobalCallStore(跨域共享)           │
│         userInfoMap / isMinimized            │
└─────────────────────────────────────────────┘
                     ↕
┌─────────────────────────────────────────────┐
│           服务层(无状态,纯原子能力)          │
│   SignalingService    │   RtcChannelService  │
│   (发/收信令)          │   (join/leave/track) │
└─────────────────────────────────────────────┘
                     ↕
┌─────────────────────────────────────────────┐
│           基础设施层(外部 SDK)               │
│        环信 IM SDK  │  声网 RTC SDK          │
└─────────────────────────────────────────────┘

关键设计决策

层级 策略 理由
UI 层 单聊/群聊彻底隔离 单聊是"一对一窗口+拖拽",群聊是"多方网格+主视频",交互模式差异巨大,强行复用只会增加复杂度
状态层 领域隔离 + GlobalCallStore 共享 单聊是二元状态机(IDLE → INVITING → IN_CALL → IDLE),群聊是分布式参与者集合;但 userInfoMap(头像/昵称)、isMinimized 需要跨域共享
服务层 无状态共享 sendInviteMessagejoinChannelcreateAudioTrack 是通用能力,不应绑定任何业务状态
基础设施 单实例共享 IM 连接和 RTC 客户端各一个实例,避免资源浪费

自动显隐:开发者不用写 v-if

单聊组件内置了状态驱动的显隐逻辑:

  • INVITING(呼叫中)→ 显示等待界面
  • ALERTING(被叫响铃)→ 不显示,由 InvitationNotification 接管弹窗
  • IN_CALL(通话中)→ 显示视频流界面
  • IDLE(空闲)→ 自动隐藏

这意味着你把组件往模板里一放,剩下的交给 CallKit。

类型安全的事件系统

不像传统 EventBus 的 any 类型,useCallKitEvents() 提供完全类型化的事件订阅:

onCallEnded((e) => {
  e.reason      // HANGUP_REASON 枚举,有代码提示
  e.duration    // number,毫秒
  e.conversationId // string,单聊=对方ID,群聊=groupId
  e.isLocal     // boolean,true=本端挂断
})

每个事件都返回解绑函数,组件卸载时自动清理,告别内存泄漏。

过期信令自动过滤

多端登录、离线重连时,过期的邀请/取消信令会堆积。CallKit 内置了时间戳过期判断(invite 40s 阈值,cmd 60s 阈值),自动丢弃过期消息,避免"幽灵弹窗"。


📦 更多能力一览

特性 说明
📞 单人通话 1v1 音频/视频,支持呼叫、接听、拒绝、挂断
👥 群组通话 多人音视频,支持邀请成员、视频网格布局
🔔 邀请通知 被叫方自动弹出接听/拒绝弹窗
🎛️ 媒体控制 静音、开关摄像头、切换前后置
🖼️ 视频布局 单聊悬浮窗+最小化;群聊九宫格/主视频模式
🎯 自动显隐 根据通话状态自动显示/隐藏,无需手动 v-if
📊 通话记录 getCallRecord() 自动生成标准化记录
🔧 源码调试 支持 Vite alias 映射到源码,开发时热更新

📝 适用场景

  • 社交 App:1v1 语音/视频通话、多人语音房
  • 在线教育:1v1 答疑、小班课视频互动
  • 远程医疗:医患视频问诊
  • 企业协作:群组视频会议、远程面试
  • 任何已有环信 IM 的项目:聊天功能已经用了环信,直接叠加通话能力

😁效果图片

待接听

image.png

视频通话中

image.png

群组通话中

image.png

被叫待接听

image.png

🔗 相关链接

资源 链接
📘 GitHub 仓库 github.com/Easemob-Com…
📖 完整 API 文档 USAGE.md
🚀 体验地址 线上地址
🏢 环信官网注册 www.easemob.com/
📦 npm 包 easemob-chat-callkit-vue3

💡 写在最后

音视频通话是现代应用的标配,但自建成本极高。Easemob Chat CallKit Vue3 的思路是:把通用能力下沉到组件库,把业务逻辑留给开发者。你只需要关心"什么时候发起通话"和"通话结束后做什么",中间最复杂的信令协商、状态同步、视频渲染,全部封装在组件内部。

如果你正在用 Vue3 开发即时通讯应用,或者正为集成音视频通话头疼,不妨试试这个方案。注册环信账号、创建应用、获取 App Key,几分钟就能跑通第一通电话。

环信官网注册入口 👉 www.easemob.com/

注册后即可创建应用,免费体验完整的 IM + 音视频通话能力。


本文介绍的 Easemob Chat CallKit Vue3 基于 MIT 协议开源,欢迎 Star 和 PR。

[Vue]可重置的响应式状态reactive

本文介绍了一个Vue框架下的可重置的响应式状态创建函数,用于创建出可重置的reactive。

源码

useResettableState.ts

import { reactive } from 'vue';
import { cloneDeep } from 'lodash-es';

/**
 * 创建一个可重置的响应式状态
 * @param initialStateFactory 返回初始状态的函数
 * @returns 包含响应式 state 和 reset 方法的对象
 */
export function useResettableState<T extends Record<string, any>>(initialStateFactory: () => T) {
  // 获取初始状态并深度克隆(用于后续重置)
  const initialState = cloneDeep(initialStateFactory());

  // 创建响应式状态(深度克隆避免引用共享)
  const state = reactive(cloneDeep(initialState)) as T;

  /**
   * 重置状态为初始值
   */
  const reset = (): void => {
    const freshState = cloneDeep(initialState);
    // 清除当前所有属性(处理动态增删字段的场景)
    Object.keys(state).forEach((key) => {
      delete (state as Record<string, any>)[key];
    });
    // 恢复初始结构
    Object.keys(freshState).forEach((key) => {
      (state as Record<string, any>)[key] = freshState[key];
    });
  };

  return {
    state,
    reset,
  };
}

使用示例

import { useResettableState } from '@/tools/composables/useResettableState';

// 一个表单对象
const { state: stateForm, reset: resetStateForm } = useResettableState(() => ({
  name: '',
  type: 'user' as 'user' | 'system'
  isEnabled: true, 
  file: undefined as File | undefined, 
}));

function submitForm() {
  // 模拟提交
  ...
  
  // 提交成功后重置表单
  resetStateForm();
}

Vue 全局监控用户行为,最强方案!

📊 产品定位

WebTracing 是一款基于 JavaScript 开发的前端埋点工具包(SDK),专门为 Web 应用打造全链路监控方案,能够全方位覆盖前端监控场景,助力开发者实现应用的精准监控与优化。

🌟 核心能力

该SDK全面覆盖八大核心监控维度,实现前端场景无死角监控:

  • 行为监控:精准追踪用户各类交互操作,还原用户行为路径
  • 性能监控:深入分析页面加载全过程及运行时性能表现
  • 异常监控:自动捕获 JavaScript 运行过程中的各类错误
  • 请求监控:实时追踪 HTTP 请求的状态、耗时等关键指标
  • 资源监控:细致分析静态资源的加载速度与异常情况
  • 路由监控:适配 SPA 应用,精准追踪路由切换状态
  • 曝光监控:检测页面元素的可见性,统计曝光数据
  • 录屏功能:回放用户操作行为,便于问题回溯与分析

✨ 技术特性

  • 原生兼容:采用纯 JavaScript 开发,支持所有现代主流浏览器
  • 框架适配:针对性提供 Vue2、Vue3 专用版本,开箱即用
  • 轻量高效:采用轻量化设计,gzip 压缩后体积不足 15KB,不占用过多资源
  • 灵活可配:支持 20 余种定制化参数,可根据业务需求灵活调整
  • 数据优化:采用智能缓存与批量上报机制,有效降低网络开销

📦 快速集成

安装方式

# 原生 JavaScript 项目
pnpm install @web-tracing/core

# Vue2 项目
pnpm install @web-tracing/vue2

# Vue3 项目  
pnpm install @web-tracing/vue3

🌐 原生 JS 集成示例

<script src="https://cdn.jsdelivr.net/npm/@web-tracing/core"></script>
<script>
  webtracing.init({
    dsn'https://api.your-domain.com/track',
    appName'web_app',
    tracesSampleRate0.2,  // 生产环境采样率设置
    ignoreErrors: [/ResizeObserver loop/],
    beforeSendDatadata => {
      data.env"production";
      return data
    }
  })
</script>

🖥️ Vue3 集成示例

import WebTracing from '@web-tracing/vue3'

app.use(WebTracing, {
  dsn: '/track',
  performance: true,      // 开启性能监控功能
  error: {                // 精细化配置错误捕获规则
    captureUnhandledRejections: true
  },
  cacheMaxLength: 20,     // 扩大缓存队列容量
})

🔧 关键配置详解

配置项 类型 默认值 说明
tracesSampleRate number 1.0 数据采样率,取值范围为 0.1~1.0
cacheWatingTime number 1000 缓存批量上报的时间间隔(单位:ms)
scopeError boolean false Vue 专属配置,用于开启组件级错误捕获

⚡ 过滤规则配置

{
  ignoreErrors: [
    "CustomIgnoreError", 
    /^SecurityError:/
  ],
  ignoreRequests: [
    /healthcheck/,
    /.(png|css|js)$/
  ]
}

�深度解析核心功能

1. 全链路错误追踪

// 主动捕获异常并上报
webtracing.captureException(error, {
  tags: { module'checkout' },
  extra: { cartId'a1b2c3' }
})

// 监听未处理的Promise异常
window.addEventListener('unhandledrejection', e => {
  webtracing.captureException(e.reason)
})

2. 精细化性能分析

// 标记关键业务流程的开始与结束
webtracing.markStart('payment_processing')
processPayment()
webtracing.markEnd('payment_processing')

// 获取页面LCP(最大内容绘制)指标
const lcpEntry = performance.getEntriesByName('LCP')[0]
console.log(lcpEntry.startTime)

3. 智能曝光追踪

<!-- 采用声明式方式配置曝光监控 -->
<div data-exposure-track="promo_banner" data-exposure-ratio="0.6">
  <!-- 广告或需要监控曝光的内容 -->
</div>

🚀 最佳实践

生产环境推荐配置

{
  dsn'https://log.your-app.com',
  tracesSampleRate0.1,   // 高流量场景建议10%采样率
  cacheMaxLength30,      // 扩大缓存队列,减少上报次数
  cacheWatingTime2000,   // 设置2秒批量上报间隔
  ignoreErrors: [
    /^CanceledError/,
    /ResizeObserver loop/
  ]
}

用户行为追踪策略

// 封装关键转化事件追踪方法
exportconst trackConversion = (eventName, params) => {
  webtracing.track(eventName, {
    ...params,
    sessionId: getSessionId(),
    timestamp: Date.now()
  })
}

// 示例:追踪用户购买行为
trackConversion('purchase', {
orderId'ord_123'amount299.00
})

📈 监控数据示例

性能数据格式

{
  "type": "performance",
  "metrics": {
    "FCP": 1240,
    "LCP": 2850,
    "CLS": 0.08
  },
  "pageUrl": "/products/123"
}

错误数据格式

{
  "type": "error",
  "message": "Cannot read property 'price'",
  "stack": "...",
  "component": "ProductCard.vue",
  "environment": "production"
}

为什么 React 和 Vue 不一样?

依旧能记起当年 React 和 Vue 刚火时,前端之间一直有个争论:使用 React 还是使用 Vue。当年这个议题吵的热火朝天,当时就在想,为什么这两个框架会有这么大的差异?造成这些差异的原因是什么?为什么两个框架走的不同路径,但是给开发者的体验却是相似的?种种问题都在我的脑海中回荡,可惜当年还是一个初入门的小白,虽然有这些问题,但是还是没有自己找到答案。最近横向和纵向各个维度深度对比了这两个框架,答案就呼之欲出了。挺好,似乎回到了入门的起点。


一、为什么两种架构走向了不同的道路

1.1 UI 的本质是什么

要理解 React Fiber 和 Vue 响应式系统为何走向截然不同的架构路径,我们必须回到一个更根本的问题:用户界面的本质是什么。React 团队给出的答案是——UI 是状态的函数(UI = f(state))。这个看似简单的等式蕴含着深刻的架构决策:如果 UI 只是状态的纯粹映射,那么每次状态变化时,整个 UI 都应该被重新计算,框架的职责是通过 diff 算法来最小化实际的 DOM 操作。React 的虚拟 DOM 和 Fiber 架构都是这一观点的工程实现,它们假设状态变化是不可预测的、细粒度的,因此需要一个通用的运行时调度系统来处理任意复杂度的更新。这种设计赋予了 React 极强的灵活性和表达能力,但也带来了不可避免的运行时开销——每一次更新都需要走过"渲染 -> 虚拟 DOM 树构建 -> Diff -> Patch"的完整链路。

Vue 的创始人尤雨溪对这个问题给出了不同的回答。在他看来,UI 的本质是响应式数据与 DOM 之间的绑定关系。当开发者声明了一个模板(Template),其中的每一个插值表达式({{ }})、每一个指令(v-bindv-ifv-for)都是在建立数据到视图的明确映射。Vue 的核心在于:这种映射关系在编译时就可以被静态分析出来。因此,Vue 选择将大部分优化工作前置到编译阶段,通过编译器生成带有优化标记的渲染函数,让运行时的更新工作变得精准而高效。Vue 3 的 Proxy 响应式系统进一步强化了这种理念——当数据变化时,框架精确知道哪些组件、哪些 DOM 节点需要更新,不需要进行全树扫描。两种框架的分歧从这一刻起就已经注定:React 押注运行时调度的通用性和灵活性,Vue 押注编译时优化的精准性和效率。

1.2 两条路径的技术DNA

React 的技术 DNA 可以追溯到底层系统编程的启发。Fiber 架构的设计者 Andrew Clark 曾明确表示,Fiber 是对操作系统线程调度模型的借鉴。在操作系统中,进程调度器需要在多个任务之间分配 CPU 时间片,确保高优先级任务(如用户输入)能够及时响应,同时不让低优先级任务(如后台计算)饿死。React Fiber 将同样的思想引入了 JavaScript 的单线程环境:通过将渲染工作拆分为可中断的"工作单元",并利用浏览器的 requestIdleCallback 机制,React 可以在每一帧的空闲时间内执行一小部分渲染工作,高优先级更新则可以随时 "抢占" 当前工作。这种架构赋予了 React 时间切片并发渲染的能力,使得 React 能够在不阻塞主线程的前提下处理大规模组件树的更新。

Vue 的技术 DNA 则源于 数据绑定依赖追踪 。Vue 2 使用 Object.defineProperty 对数据对象进行递归劫持,在 getter 中收集依赖,在 setter 中触发更新。Vue 3 则将这一机制升级为基于 ES6 Proxy 的响应式系统,配合 Reflect API 实现更完整、更高效的拦截。Vue 的核心设计哲学是 让框架自动追踪数据与视图之间的依赖关系,开发者无需手动声明依赖(不像 React 的 useEffect 需要显式传递依赖数组)。当 refreactive 对象的值发生变化时,Vue 的响应式系统能够精确通知到依赖于该数据的每一个副作用(Effect),包括组件的重新渲染、computed 属性的重新计算、watch 回调的执行等。这种"自动追踪、精确触发"的机制,使得 Vue 在大多数场景下能够实现 O(1) 的更新复杂度 ——即更新成本与受影响的节点数量成正比,而非与组件树的总规模成正比。

1.3 核心差异一览

维度 React Fiber Vue 响应式系统
核心差异 UI = f(state),通用运行时调度 数据驱动视图,编译时优化 + 响应式追踪
更新粒度 组件级别(需要 diff 确定实际变更) 属性级别(精确追踪依赖)
调度模型 协作式多任务(Cooperative Scheduling) 依赖触发式(Dependency-driven)
可中断性 原生支持(Time Slicing) 需配合 nextTick 批量处理
编译角色 次要(JSX 转译) 核心(模板编译 + 优化标记生成)
内存模型 双缓冲(Current / WorkInProgress 两棵树) 代理对象 + Effect 依赖图
学习曲线 中等(需理解 hooks 规则、闭包陷阱) 平缓(模板语法直观)

二、React Fiber:在单线程世界里的调度器

2.1 Stack Reconciler 的困局

在 React 16 之前的 Stack Reconciler 时代,React 的更新过程可以简单概括为 "一撸到底" 。当组件状态发生变化时,React 会从根节点开始,递归遍历整棵组件树,计算新的虚拟 DOM 树,与旧的树进行 Diff,最后一次性将所有变更提交到真实 DOM。这个过程完全 同步不可中断 ——一旦开始,就必须等到全部完成才能将控制权交还给浏览器。对于小型应用,这种方式工作得很好,因为整个更新过程可能只需要几毫秒。但随着应用规模的增长,组件树可能包含数千个节点,一次完整的 reconciliation 可能消耗数十甚至上百毫秒,直接阻塞浏览器的主线程。

这种阻塞带来的用户体验问题是灾难性的。我们想象一下,用户在搜索框中输入文字,同时后台正在接收实时数据更新。在 Stack Reconciler 中,数据更新触发的重渲染可能会完全占用主线程 100ms,在这段时间内,用户的键盘输入事件被挂在事件队列中无法得到响应——用户会感觉"卡顿"。更严重的是,动画在这一期间完全停滞,因为浏览器没有机会执行 requestAnimationFrame 回调。React 团队意识到,问题的根源不在于虚拟 DOM 本身,而在于 JavaScript 的执行模型 ——调用栈是 后进先出的、不可抢占的数据结构,一旦进入深层递归,就没有优雅的方式来"暂停"当前工作去处理更紧急的任务。

2.2 Fiber 的创新:重新实现调用栈

React Fiber 的创新点在于它对这一底层问题的回应:如果浏览器的调用栈不够灵活,那就自己实现一个。Fiber 架构的本质是一种 用户空间调度器,它将原本由 JS 引擎管理的调用栈转换为显式维护的链表数据结构。每一个 React 组件实例不再只是一个函数调用,而是一个持久化的 Fiber 节点对象,其中包含了 child(第一个子节点)、sibling(下一个兄弟节点)和 return(父节点)三个指针,构成了一棵可任意遍历、暂停和恢复的树形链表。

这种数据结构的选择绝非偶然。链表结构使得 React 可以彻底放弃递归(recursion),改用循环(loop)来遍历组件树。在循环的每一次迭代中,React 处理一个 Fiber 节点,然后检查当前帧的剩余时间。如果剩余时间不足(React 默认设置了一个约 5ms 的帧预算),或者检测到有更高优先级的更新到来,React 可以立即保存当前的工作进度(记录下一个待处理的 Fiber 节点引用),将控制权交还给浏览器,然后在下一帧的 requestIdleCallback 回调中无缝恢复工作。Andrew Clark 将 Fiber 描述为 "一个专门用于 React 组件的虚拟栈帧" ——它的核心优势在于,这些栈帧存储在堆内存中,React 可以完全控制它们的执行顺序和时机,这是操作系统调用栈所不具备的能力。

2.3 双缓冲架构与两阶段提交

Fiber 架构引入了 双缓冲 的内存模型,这是另一个深刻影响 React 更新行为的创新。React 在内存中同时维护两棵 Fiber 树:一棵是 current 树,代表了当前屏幕上真实 UI 的状态;另一棵是 workInProgress 树,用于进行正在进行的渲染计算。当更新触发时,React 并不会直接修改 current 树,而是基于它克隆出一棵 workInProgress 树,所有的 reconciliation 工作都在这棵"草稿"树上进行。这个设计的精妙之处在于 渲染过程完全不会影响用户看到的界面——即使渲染过程中途被中断或完全丢弃,用户看到的依然是 current 树对应的一致 UI。

workInProgress 树的所有工作完成后,React 进入 提交阶段(Commit Phase)。这是一个 同步、不可中断 的阶段,React 将 workInProgress 树的所有副作用(DOM 插入、更新、删除,以及生命周期函数和 useEffect 回调的调度)一次性应用到真实 DOM 上,然后原子性地将 workInProgress 树切换为新的 current 树。两阶段架构的严格分离是 React 并发特性的基石:渲染阶段(Render Phase)可以被打断和重启,因为它只操作内存中的 workInProgress 树;提交阶段(Commit Phase)必须是原子的,因为此时正在修改用户可见的界面,任何不一致都会导致视觉闪烁。

graph TD
    A[状态更新触发] --> B{是否有更高<br/>优先级任务?}
    B -->|是| C[保存当前进度<br/>yield 控制权]
    C --> D[处理高优先级任务]
    D --> E[恢复之前工作]
    E --> B
    B -->|否| F[Render Phase<br/>构建 workInProgress 树]
    F --> G[生成 Effect List]
    G --> H[Commit Phase<br/>同步提交 DOM 变更]
    H --> I[切换 current 指针]
    I --> J[调度 useEffect]

2.4 优先级调度与 Lane 模型

React 18 进一步演化出了 Lane 优先级模型,用 31 位的二进制数来表示不同类型的更新优先级。每一位代表一个"通道"(Lane),不同的交互类型(用户输入、点击、数据加载、过渡动画等)被分配到不同的 Lane 上。React 可以精确判断哪些更新更紧急,并支持 Lane 的"纠缠"(entanglement)机制——当高优先级更新和低优先级更新之间存在数据依赖时,React 会自动将它们合并渲染,防止出现视觉不一致。这种精细的优先级控制系统使得 React 能够在极端复杂的并发场景中依然保持用户交互的流畅性,但也显著增加了框架的运行时复杂度和学习成本。


三、Vue 响应式系统:让数据自己告诉你它变了

3.1 从 defineProperty 到 Proxy:响应式技术的进化

Vue 的响应式系统经历了两代重大演进。Vue 2 使用 Object.defineProperty 为对象的每一个属性定义 getter 和 setter,在属性被读取时收集依赖,在被修改时触发更新。这个方案在当时是创新的,但它有几个根本性缺陷:首先,Object.defineProperty 只能拦截已经存在的属性,无法检测对象的新增属性和数组索引的变化(这也是 Vue 2 需要 Vue.setVue.delete API 的原因);其次,它需要对数据对象进行 深度递归遍历,在初始化时就为每一层嵌套对象的每一个属性都设置 getter/setter,这在处理大型数据对象时会产生大的性能开销。

Vue 3 的响应式系统基于 ES6 的 Proxy 对象进行了彻底重写。与 Object.defineProperty 不同,Proxy 可以拦截对目标对象的 任何操作 ——包括属性读取、赋值、删除、枚举、函数调用、in 运算符,甚至 new 操作。这意味着 Vue 3 不再需要深度递归初始化:代理是"懒"的,只有当访问到某个嵌套对象时,才会递归地为该对象创建代理。更重要的是,Proxy 让 Vue 3 天然支持 Map、Set、WeakMap、WeakSet 等 ES6 数据结构,以及数组的所有操作(包括直接通过索引赋值和修改 length),无需任何特殊处理。

在 Vue 3 的源码中,reactive() 函数通过 new Proxy(target, mutableHandlers) 创建响应式对象,其中 mutableHandlers 包含了 getset 拦截器。get 拦截器使用 Reflect.get(target, key, receiver) 读取属性值(Reflect API 的设计目的正是为了与 Proxy 配合使用,提供更完整和规范的元编程能力),同时调用 track() 函数进行依赖收集;set 拦截器使用 Reflect.set() 写入新值,然后调用 trigger() 函数通知所有依赖进行更新。这种 Proxy + Reflect 的组合已经成为现代 JavaScript 元编程的标准范式。

3.2 依赖收集的三剑客:TargetMap、Dep、Effect

Vue 3 的响应式系统内部维护了一个精巧的全局依赖追踪结构。其核心是三个关键数据结构:

首先是 targetMap,一个 WeakMap<object, Map<string | symbol, Set<ReactiveEffect>>> 结构。它的作用是建立"响应式对象 -> 属性键 -> 依赖集合"的三层映射。WeakMap 的选择非常重要——它允许垃圾回收器在响应式对象不再被引用时自动回收其对应的依赖信息,防止内存泄漏。当 track() 被调用时,Vue 会根据当前被访问的响应式对象和属性键,找到或创建对应的依赖集合(Dep),然后将当前正在执行的 ReactiveEffect 实例添加到这个集合中。

其次是 ReactiveEffect 类,它是 Vue 响应式系统中"副作用"的抽象表示。组件的渲染函数、computed 属性的计算函数、watch 的回调函数,本质上都是 ReactiveEffect 的不同实例。每个 ReactiveEffect 有一个 run() 方法用于执行副作用,以及一个 deps 数组用于记录它依赖于哪些 Dep 集合。这种 双向记录 的机制——Effect 记录它依赖了哪些 Dep,Dep 记录哪些 Effect 依赖了它——是实现精确更新的关键。当响应式数据变化时,trigger() 函数只需要找到对应的 Dep 集合,遍历其中的所有 Effect 并重新执行即可。

最后是调度器(Scheduler)。Vue 并不会在数据变化时立即同步执行所有副作用,而是将它们推入一个队列,通过 nextTick 机制进行 异步批量刷新。这意味着在同一个事件循环中发生的多个数据变化,只会触发一次统一的 DOM 更新——这是 Vue 性能优化的重要手段。通过 Promise.then(或降级到 setImmediate / setTimeout),Vue 确保所有同步的数据变更都完成后,才在下一个微任务中执行副作用,这种批量处理策略大幅减少了不必要的重复渲染。

graph LR
    A[响应式对象 Proxy] -->|读取属性| B[track]
    B --> C{targetMap}
    C -->|对象| D[Map: key -> Dep]
    D -->|属性| E[Set of Effects]
    E -->|添加| F[当前 Effect]
    
    G[修改属性] -->|触发| H[trigger]
    H --> C
    D -->|获取 Effects| I[批量调度更新]
    I -->|nextTick| J[执行 Effect/DOM更新]

3.3 编译器的智慧:从模板到优化标记

Vue 的响应式系统之所以高效,很大程度上归功于其 编译器的静态分析能力。与 React 的 JSX 不同,Vue 使用基于 HTML 的模板语法。这种看似限制性的设计实际上为编译器优化打开了巨大的空间。当 Vue 编译器分析一个模板时,它能够识别出哪些部分是 静态的(不会随数据变化),哪些是 动态的(绑定响应式数据)。

Vue 3 的编译器引入了多项革命性的优化技术:静态提升(Static Hoisting)将静态节点从渲染函数中提取出来,只在首次渲染时创建一次,后续更新完全跳过这些节点;Patch Flags 为每一个动态节点打上一个优化标记,精确指示该节点的哪个部分可能变化(文本内容、类名、样式、属性等),这样运行时的 diff 算法可以跳过完整的 props 比较,只检查可能发生变化的特定部分;树扁平化 打破了传统的递归 diff 模式,将所有动态节点收集到一个扁平数组中,diff 时只需要遍历这个数组而非整棵树。这些编译时优化的综合效果,使得 Vue 3 的虚拟 DOM 更新效率远超传统的全树 diff 实现——虽然 Vue 仍然使用虚拟 DOM,但它已经是一个被编译器"武装到牙齿"的高度优化版虚拟 DOM。


四、业内其他框架:百花齐放的方案

4.1 Svelte:编译器即框架

如果说 React 代表了 "运行时最大化" 的极端,那么 Svelte 则代表了 "编译时最大化" 的另一个极端。Svelte 的创造者 Rich Harris 提出了一个激进的问题:如果框架在构建时就知道你的组件会如何变化,那为什么还要在运行时做这些工作。Svelte 的核心架构决策是将框架本身"编译掉"——最终运行在浏览器中的代码,几乎是纯粹的手写 JavaScript DOM 操作,没有虚拟 DOM,没有响应式运行时库,没有 diff 算法。

Svelte 5 进一步引入 Runes(如 $state$derived$effect),将响应式模型从隐式的编译器魔法转变为显式的信号(Signals)机制。编译器分析组件模板中的每一个响应式绑定,生成精确的 DOM 更新代码。当 $state 的值变化时,编译生成的代码直接调用 textNode.data = newValueelement.setAttribute('class', newClass),没有任何中间抽象层。这种架构的代价是 Svelte 需要一个功能强大的编译器来处理各种边界情况,但它的回报也是巨大的:Svelte 应用的运行时体积极其微小(约 2-3 KB gzip),更新性能接近原生 JavaScript,内存占用也远低于虚拟 DOM 方案。

4.2 SolidJS: Signals 驱动的细粒度响应式

SolidJS 的创造者 Ryan Carniato 将"细粒度响应式"(Fine-grained Reactivity)推向了一个极致。Solid 同样不使用虚拟 DOM,但它与 Svelte 的编译器驱动方式有所不同:Solid 保留了 JSX 语法,其编译器将 JSX 转换为高效的 DOM 创建和更新指令,而响应式追踪则在运行时通过 Signals 完成。Solid 的 createSignal 返回一个 getter/setter 对,当在 JSX 或其他响应式上下文中读取 signal 时,依赖关系被自动建立;当 signal 值变化时,只有直接依赖于该值的 DOM 节点会被更新。

SolidJS 的一个关键设计特点是 组件函数只执行一次。这与 React(组件函数在每次渲染时都重新执行)和 Vue(渲染函数在每次更新时重新执行)有着根本不同。在 Solid 中,组件的 setup 代码只在挂载时运行一次,后续所有的更新都通过信号系统精确到达对应的 DOM 节点,无需重新执行组件函数。这种设计消除了"重新渲染"的概念,从根本上避免了虚拟 DOM 方案中因组件重渲染而产生的计算开销。在 js-framework-benchmark 中,SolidJS consistently 排名最靠前,与原生 JavaScript 的性能差距极小,这验证了细粒度响应式架构在性能上的巨大潜力。

4.3 Angular:从 Zone.js 到 Signals 的转变

Angular 作为一个历史悠久的企业级框架,其架构演进代表了另一个维度的思考。长期以来,Angular 依赖 Zone.js 进行变化检测——Zone.js 通过猴子补丁(monkey-patching)浏览器的所有异步 API(setTimeout、Promise、XHR、DOM 事件等),在任何异步操作完成后自动触发 Angular 的全局变化检测。这种方案的优点是开发者完全不需要关心何时触发更新——任何异步操作后 Angular 都会自动检查所有组件是否需要更新;缺点是性能极差,因为即使是最微小的状态变化,也可能导致整个组件树的脏检查(Dirty Checking)。

Angular 16+ 开始引入 Signalssignal()computed()effect()),标志着 Angular 正在从 Zone.js 的全局脏检查模型向细粒度响应式模型迁移。Angular 的 Signals 设计与 SolidJS 类似,但提供了更渐进式的迁移路径——开发者可以逐步将组件从 Zone.js 迁移到 Signals,而无需重写整个应用。Angular 的转变印证了一个行业趋势:细粒度响应式正在成为前端框架的共识方向,即使是传统上采用完全不同架构的框架也在向这一方向靠拢。

4.4 各框架架构对比

框架 渲染策略 响应式模型 运行时体积 更新粒度 编译角色
React 19 Virtual DOM + Fiber Hooks + 自动 memoization ~45 KB 组件级 React Compiler (构建时 memo)
Vue 3 Compiler-optimized VDOM Proxy + Effect 追踪 ~34 KB 属性级 核心(静态提升、Patch Flags)
Vue Vapor 无 VDOM(直接 DOM) Proxy + Effect 追踪 ~10 KB 属性级 核心(编译为 DOM 操作)
Svelte 5 无 VDOM(编译后代码) Runes (Signals) ~3 KB 语句级 核心(编译器即框架)
SolidJS 无 VDOM(编译后代码) Signals (createSignal) ~7 KB DOM 节点级 JSX 编译 + 运行时追踪
Angular 19 incremental DOM Signals (迁移中) ~120 KB 属性级 AOT 编译 + Signals

前端框架运行时性能对比

前端框架 Bundle 体积对比

前端框架架构演进时间线


五、两种框架什么场景下使用(不一定对)

5.1 性能特性的场景化分析

React Fiber 的并发调度能力在大规模、高频率、高并发更新的场景下展现出独特优势。比如一个复杂的股票交易仪表盘:页面上有数十个实时数据流(价格、成交量、订单深度),同时用户正在与图表交互(缩放、平移、选择时间范围)。React 的 Fiber 架构允许 用户交互(高优先级)实时打断数据更新(低优先级),确保图表操作始终保持 60fps 的流畅度,而价格数据在后台以较低优先级逐步更新。如果没有 Fiber 的调度能力,大量数据更新可能导致用户交互出现明显卡顿。React Compiler(原 React Forget)进一步通过编译时自动插入 memoization 来减少不必要的重渲染,让开发者不再需要手动管理 useMemouseCallback

Vue 的响应式系统在大多数常规应用场景下提供更优异的更新效率和开发体验。由于 Vue 精确追踪了每一个数据属性与视图之间的依赖关系,更新成本天然地与变更的影响范围成正比,而非与组件树的总规模成正比。这意味着在一个包含 1000 个组件的页面中,如果只有底部一个计数器发生变化,Vue 只需要更新那个计数器对应的 DOM 节点,而 React(在没有 Compiler 优化的情况下)可能需要重新渲染整个受影响的组件子树,然后进行 diff。Vue 3.6 的 Vapor Mode 进一步将这一优势推向极致:对于使用 Composition API 的组件,Vapor Mode 可以在编译时直接生成 DOM 操作代码,完全跳过虚拟 DOM,实现与 SolidJS 媲美的性能。

5.2 开发者体验:心智模型与学习曲线

React 的编程模型更接近 JavaScript 的函数式编程范式。Hooks(useStateuseEffectuseMemo 等)的引入虽然解决了类组件的逻辑复用问题,但也带来了新的心智负担:hooks 的调用顺序必须严格一致(不能在条件语句中调用),依赖数组需要手动维护(遗漏依赖会导致 bug),闭包陷阱(stale closure)是新手最常遇到的问题之一。React 的灵活性是一把双刃剑——它允许你以几乎任何方式组织代码,但也意味着团队需要建立严格的代码规范来保持一致性。React Compiler 的出现正在缓解这些问题,通过编译时自动优化替代了大部分手动 memoization 的工作。

Vue 的编程模型则更加 约定优于配置(Convention over Configuration)。模板语法({{ }}v-ifv-forv-bind)对前端开发者来说非常直观,因为它们本质上就是增强的 HTML。Composition API 提供了与 React Hooks 类似的逻辑组合能力,但没有了调用顺序的限制,也没有了依赖数组——因为 Vue 的响应式系统 自动追踪依赖,开发者不需要手动声明。这种"自动依赖追踪"的设计极大地减少了与响应式相关的 bug。对于初学者来说,Vue 的渐进式设计意味着可以从一个简单的 script 标签引入开始,逐步学习到完整的单文件组件(SFC)、Composition API、状态管理(Pinia)和路由(Vue Router),每一步都有明确的指导路径。

5.3 生态

React 的生态系统无疑是前端领域最为庞大和成熟的。从状态管理(Redux、Zustand、Jotai、Recoil)到路由(React Router)、元框架(Next.js、Remix)、UI 组件库(Material-UI、Ant Design、Chakra UI)、表单处理(React Hook Form、Formik)、数据获取(TanStack Query、SWR),React 生态几乎覆盖了前端开发的每一个细分领域。Next.js 的 App Router 和 React Server Components (RSC) 代表了 React 生态在服务端渲染和全栈开发方向上的最新探索。对于大型企业和团队来说,React 生态的广度和深度意味着几乎任何需求都能找到成熟的解决方案,招聘拥有 React 经验的开发者也相对容易。

Vue 的生态系统虽然规模不及 React,但其 整合度更高、一致性更好。Vue 官方维护的核心生态库(Vue Router、Pinia、Vite、VueUse、Nuxt.js)在 API 设计和发布节奏上保持高度统一,这大大降低了开发者在不同库之间切换的认知成本。Nuxt.js 作为 Vue 的官方元框架,提供了开箱即用的服务端渲染、静态生成、API 路由、自动导入等全栈功能,其开发者体验在很多方面优于 Next.js。


六、融合的未来:架构趋同与各自进化

6.1 从对立到融合的行业趋势

一个值得思考的现象是:React 和 Vue 虽然起源于完全不同的架构哲学,但是在最近的两年里,两条技术路线正在呈现出 趋同。React 通过 React Compiler 在编译时自动完成原本需要手动进行的 memoization 优化,实质上是在"借用"编译时优化的思路来弥补虚拟 DOM 的性能缺陷;Vue 通过 Vapor Mode 探索无虚拟 DOM 的编译策略,实质上是在向 Svelte/Solid 的细粒度响应式范式靠拢。两者都在向对方擅长的领域延伸——React 增强编译时能力,Vue 增强运行时调度能力。

这种趋同也不是偶然,而是前端架构演进的必然结果。无论是虚拟 DOM 还是细粒度响应式,最终目标都是"在状态变化时高效地更新界面"。虚拟 DOM 的方案通过"通用运行时 diff"解决问题,优点是灵活性和可预测性,缺点是运行时开销;细粒度响应式的方案通过"编译时/运行时精确追踪"解决问题,优点是极致的性能,缺点是更强的编译依赖和对编程模式的约束。最优的架构必然是在两者之间找到平衡点——利用编译器做尽可能多的静态分析和优化,同时保留运行时的调度能力来处理动态和不可预测的场景。

6.2 React 的未来:Compiler + Server Components

React 团队正在全力推进三个方向的进化:React Compiler(构建时自动 memoization)、React Server Components(服务端组件,零客户端 bundle)、以及 Offscreen Rendering(离屏渲染,用于预加载和保留组件状态)。React Compiler 的成熟将从根本上改变 React 的性能优化范式——开发者不再需要手动编写 useMemouseCallbackReact.memo,编译器会在构建时自动完成这些优化,且粒度通常比手动优化更细。React Server Components 则代表了 React 对"如何减少客户端 JavaScript 体积"这一问题的回答:将纯数据展示型组件放在服务端执行,只将交互型组件发送到客户端。这种服务器优先的架构(Server-first Architecture)正在通过 Next.js 的 App Router 成为 React 生态的主流范式。

6.3 Vue 的未来:Vapor Mode + Alien Signals

Vue 的未来路线图同样清晰而且让人期待。Vapor Mode 的目标是让 Vue 在保持现有 API 不变的前提下,实现 SolidJS 级别的渲染性能——通过在编译时生成直接 DOM 操作代码,完全跳过虚拟 DOM。这意味着 Vue 开发者无需改变任何编程习惯,只需开启一个编译器选项,就能获得数倍的渲染性能提升。Vue 3.6 还在开发 Alien Signals——一套与框架无关的信号系统实现,旨在让 Vue 的响应式原语可以与其他信号库互操作。长期来看,Vue 的架构愿景是成为一个可适应不同场景的灵活系统:对于简单场景,Vapor Mode 提供极致性能;对于复杂场景,编译器优化的虚拟 DOM 提供完整的特性支持;响应式系统作为独立模块,可以与任何渲染层配合使用。

七、总结

React Fiber 和 Vue 响应式系统代表了前端架构设计中两种取向。React 选择了一条更接近计算机科学底层的路:重新设计调用栈,实现用户空间调度器,以通用性和灵活性为代价,换来了对极端并发场景的掌控力。Vue 选择了一条更接近应用开发本质的路:让数据自己说话,让编译器做苦力,以更强的编译时约束为代价,换来了大多数场景下的高效和优雅。

这两种选择没有高下之分,它们是前端技术生态的 阴阳两面——一方的创新会激发另一方的进化。Fiber 的并发调度启发了 Vue 对异步更新队列的重构;Vue 的编译器优化启发了 React Compiler 的方向;Svelte 的编译器范式启发了 Vue Vapor Mode 的探索;SolidJS 的细粒度响应式启发了 Angular Signals 的迁移。

这种跨框架的相互启发和借鉴,恰恰说明前端架构的进化不是线性的,而是辩证的。每一个看似对立的技术选择,实际上都在推动整个行业向前发展。React 的 Fiber 证明了在 JavaScript 单线程环境中实现复杂调度的可行性;Vue 的编译器证明了静态分析在现代 UI 框架中的巨大价值;Svelte 的编译器范式证明了"没有运行时"的可能性;SolidJS 的 Signals 证明了细粒度响应式的性能极限。这些探索共同构成了前端技术栈的知识积累,无论最终哪个框架占据主流,整个行业都从中受益。

[前端]单文件上传组件

本文介绍了一个单文件上传前端组件,基于Vue3、ElementPlus,提供了组件源码及使用示例教程,可供参考和使用。

支持的功能:文件覆盖、限制文件类型、最大文件大小

组件源码

<!--
  * 单文件上传组件
  * 
  * Author: GFire
  * Date: 2025/01/16
-->
<template>
  <div>
    <el-upload
      ref="upload"
      :limit="1"
      :accept="props.accept"
      :on-exceed="handleExceed"
      :on-change="handleChange"
      :on-remove="handleRemove"
      :auto-upload="false"
    >
      <!-- 默认插槽,用于放置触发文件选择的元素,如按钮、文字等 -->
      <slot name="default"></slot>
      <template #tip>
        <div style="font-size: 12px; color: var(--el-color-info)">
          <div v-if="props.accept">支持的文件类型:{{ props.accept }}</div>
          <div v-if="props.maxFileSize">支持的最大文件大小:{{ props.maxFileSize.size + props.maxFileSize.unit }}</div>
        </div>
        <!-- 用户自定义提示内容插槽 -->
        <slot name="tip"></slot>
      </template>
    </el-upload>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ElNotification, genFileId } from 'element-plus';
import type { UploadInstance, UploadProps, UploadRawFile, UploadFile } from 'element-plus';

type SizeUnit = 'KB' | 'MB' | 'GB';

const props = defineProps<{
  /**
   * 接受上传的文件类型,以文件后缀用逗号拼接的字符串,如:`.jpg,.txt,.xlsx`,不传则无限制
   */
  accept?: string;
  /**
   * 支持的最大文件大小,不传则无限制
   */
  maxFileSize?: { size: number; unit: SizeUnit };
}>();

const emit = defineEmits<{
  (event: 'fileChange', file?: File): void;
}>();

defineExpose({
  /**
   * 清空文件列表
   */
  clearFile() {
    upload.value!.clearFiles();
  },
});

const upload = ref<UploadInstance>();
let tempFile: UploadFile | undefined;

// 覆盖前一个文件
const handleExceed: UploadProps['onExceed'] = (files) => {
  upload.value!.clearFiles();
  const file = files[0] as UploadRawFile;
  file.uid = genFileId();
  upload.value!.handleStart(file);
};

function handleChange(uploadFile: UploadFile) {
  if (!isValidFile(uploadFile)) {
    // 文件不合法,回退
    rollback();
  } else {
    tempFile = uploadFile;
    emit('fileChange', uploadFile.raw);
  }
}

// 校验文件是否合法
function isValidFile(uploadFile: UploadFile) {
  if (!isValidFileType(uploadFile)) {
    ElNotification.error({
      title: '文件不合法',
      message: `文件类型不支持,需为:${props.accept}`,
      position: 'top-right',
    });
    return false;
  }

  if (!isValidFileSize(uploadFile)) {
    ElNotification.error({
      title: '文件不合法',
      message: `文件大小超过限制:${props.maxFileSize?.size} ${props.maxFileSize?.unit}`,
      position: 'top-right',
    });
    return false;
  }

  return true;
}

function rollback() {
  if (tempFile) {
    upload.value!.clearFiles();
    upload.value!.handleStart(tempFile.raw!);
  } else {
    upload.value!.clearFiles();
  }
}

function handleRemove() {
  tempFile = undefined;
  emit('fileChange', undefined);
}

const acceptTypes = props.accept?.split(',');
function isValidFileType(uploadFile: UploadFile) {
  // 无值,代表接受任意文件类型
  if (!acceptTypes) {
    return true;
  }

  const fileType = '.' + uploadFile.name.split('.').pop();
  for (let type of acceptTypes) {
    if (fileType === type) {
      return true;
    }
  }
  return false;
}

function isValidFileSize(uploadFile: UploadFile) {
  // 无值,代表文件大小无限制
  if (!props.maxFileSize) {
    return true;
  }

  let bytes = convertToBytes(props.maxFileSize.size, props.maxFileSize.unit);
  if (uploadFile.raw!.size > bytes) {
    return false;
  } else {
    return true;
  }
}

function convertToBytes(size: number, unit: SizeUnit) {
  const unitMapping = {
    KB: 1024,
    MB: 1024 * 1024,
    GB: 1024 * 1024 * 1024,
  };

  const multiplier = unitMapping[unit];
  if (multiplier) {
    return size * multiplier;
  } else {
    throw new Error('Unsupported unit. Please use KB, MB, or GB.');
  }
}
</script>

<style scoped></style>

使用示例

示例代码:

<template>
    <SingleFileUpload
      style="width: 300px"
      ref="fileUploadRef"
      accept=".md,.txt"
      :maxFileSize="{ size: 50, unit: 'KB' }"
      @fileChange="handleFileChange"
    >
      <el-button>选择文件</el-button>
      <template #tip> 请上传符合要求的文件 </template>
    </SingleFileUpload>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue';
import SingleFileUpload from '@/components/base/SingleFileUpload.vue';

const fileUploadRef = ref();
// 接收文件变更
function handleFileChange(file: File | undefined) {
  form.file = file;
}

const form = reactive({
  file: undefined as File | undefined,
});

function submitForm() {
  // 模拟提交表单
  console.log('提交表单:', form);

  // 清空文件
  fileUploadRef.value.clearFile();
}
</script>

代码解释:

  • accept=".md,.txt":指定只接受md、txt的文件
  • :maxFileSize="{ size: 50, unit: 'KB' }":指定支持的最大文件大小为50KB
  • @fileChange="handleFileChange":文件变化事件处理

显示效果:

image.png

选择文件,默认限制为提供的文件类型(md、txt):

image.png

选择文件后的效果:

image.png

当选择的文件大小超过限制,则提示异常:

image.png

当选择的文件类型不支持,则提示异常:

image.png

从0到1一步步拆解搭建,梳理一个 Vue3 简易图书后台全开发流程

为什么要写这样一篇文章?

一个普通的甚至不太够看的后台图书管理系统,能够正常运行、实现基础业务功能就足够了,为什么还要花费大量时间,去从头到尾梳理一遍甚至写成文章呢?

写这个文章之前我也去思考了这件事的必要性,得出了下面这四条

有四个层次的意义

第一层:工具层面:更加熟练、通透地理解 Vue 整套开发工具链,明白工具的用法、适用场景与设计逻辑,学会去使用现在掌握和学习的工具。

第二层:项目理解层面:跳出单一语法与页面开发,站在项目整体角度去思考架构分层、代码封装、业务逻辑、工程设计,理解一个完整项目究竟该如何搭建,学习完之后尝试去自己设计项目。

第三层:个人层面:通过完整复盘沉淀,慢慢尝试搭建属于自己的,清晰、完整、闭环的前端开发体系,为之后更好地使用工具、开发项目打下扎实基础,同时也是对于以后拓展工具完善体系有一个参照。

第四层:也是这篇文章的意义:希望把自己的思路完整分享出来。对于入门学习者,可能是一种不一样的思考角度;同时也期待行业里有经验的开发者能够阅读点评,指出我理解不到位、思考有偏差的地方,让我从自己没有注意的视角查漏补缺,修正自己的错误,提升自己的认知。

所以接下来,我将从零开始,正向完整梳理这个简单项目从构思、搭建到开发落地的全部过程。

前置认知:浅谈项目开发思路、学习逻辑与技术选型

在正式进入项目开发之前,先浅浅的聊聊我理解的项目开发思路。

框架和各类开发工具,本身就是为落地项目而诞生的,本质上属于项目驱动学习

正常完整的开发逻辑,应当是先拿到业务需求,对项目整体进行完整分析,确定业务场景、功能需求,再根据项目体量去挑选合适的技术栈与开发工具。 (这一步整体规划分析,其实也是开发里难度很高、很考验思维的一环。)

本次项目是以学习理解为主,没有严格的业务要求与上线标准,因此我并没有按照标准项目流程先需求后选型。而是以现阶段需要学习掌握的技术为核心,反向完成技术选型。

最终选用 Vue3 + Vite+ Element Plus + VueRouter + Pinia + Axios 技术栈

整体页面包含登录页系统布局首页图书管理模块个人中心页面这么几个内容模块。

用完整项目载体,反过来带动工具理解、框架熟悉与工程思维落地。

(所以具体这个技术选型和原因这里就不细说,不是因为它不重要,反而是太重要(对于我目前现阶段的认知和能力,还不足以完整、专业地讲出来底层选型逻辑),但是必须要清楚,这个项目的选型的方式只是学习阶段的方式,真正正规的项目开发顺序,绝对不能本末倒置。)

聊一下项目最核心第一步:项目基础工程构建

有句老话讲“万事开头难”,一点不错。咱们就来看看这个开头难在哪

整个项目构建的核心第一步,其实整体可以分为两大环节。

第一环节相对简单

以咱们的这个项目为例,在明确整体业务需求、确定好本次项目所用技术栈之后,利用 Vite 快速初始化,创建出一个干净、基础的 Vue3 项目文件。

(这一步更多是环境搭建,依赖安装,只需要把项目基础可用环境跑通即可。)

真正核心、最考验开发思维的,是第二个环节:

依托我们已经梳理拆分好的业务需求,去精细化设计、完善项目内部完整的根目录体系。

简单一句话:业务是皮肉,工程架构才是骨架。

骨架歪了,后面功能写再多,项目也是松散、混乱、没有章法的。 骨架搭建清晰合理,后面所有业务开发都会顺水推舟,条理清晰,思路顺畅。 (可以说业务代码是下限,工程思维与项目架构构造能力,才是一个开发者的上限。特别是现在AI越来越厉害,不断在冲击下限,我们更需要去锻炼构造能力和工程思维 ,守住自己下限的同时,去提高自己的上限。)

所以我们没有一上来就写页面、写功能。 而是在业务分析完毕、技术选型确定之后,优先沉下心构建整套基础工程。 从目录划分、路由设计、状态管理、请求封装、全局配置全部提前规划,用搭建工程的过程,慢慢建立自己整体的项目开发思维。

理清这一层,我们再正式开始实操完成从零初始化结构,再到完善整个后台图书管理系统项目。

后台图书管理系统

正式开工:构建项目雏形

首先,我们使用 Vite 创建一个最纯净、无多余配置的 JS 版本 Vue3 模板,同时安装好本项目全部所需核心依赖:路由、状态管理、网络请求、UI组件库等。得到一个极简干净的项目初始环境。

环境准备完成后,我们不再急着编写页面代码,正式进入根据业务需求搭建项目目录结构阶段。

简易后台图书管理项目结构较为简单,可以拆分成权限登录全局布局图书业务管理个人中心四大核心模块,也明确了:工程化目录,绝不是一次性把所有文件夹建好,而是跟着业务模块、代码职责,逐一对号入座、逐个新建,每建一个目录,都清楚它对应哪块业务、承担什么功能。

接下来,我们就从零开始,不列最终框架,拆一个模块、建一个目录、讲清一层逻辑,一步步搭起整个项目的目录骨架。

第一步:新建项目基础核心——src根目录

Vite初始化完成后,默认只有基础的 src 文件夹,这是我们所有业务代码的唯一容器,所有模块、目录、文件,全部都在 src 内部搭建,不向外扩散。

这是最基础的规则:所有开发代码,只在src内编写,从根源避免文件散乱。

第二步:对应【页面业务模块】——新建views目录

我们最先拆分的,就是项目的页面级业务,登录、首页、图书管理、个人中心,都是独立的页面业务模块,所以第一步先新建承载所有页面的目录:

src/
├── views/  # 核心:所有业务页面容器

新建逻辑&业务对应

1. 对应前文拆分的权限登录、全局首页、图书管理、个人中心四大页面业务,所有页面都归属于此

2. 拒绝把所有 .vue 页面直接堆在 src 下,按业务模块划分子目录,后续新增页面、查找页面很清晰

3. 按照业务优先级,继续在 views 下新建子目录(按开发顺序新建,不一次性建完):

src/
├── views/
   ├── login/      # 对应【权限登录模块】:登录页面
   ├── home/       # 对应【全局布局首页模块】:系统工作台
   ├── books/      # 对应【核心图书管理模块】:图书增删改查业务
   ├── profile/    # 对应【个人中心模块】:用户信息管理

每建一个子文件夹,都对应我们拆分好的一个业务模块,完全做到业务拆分到哪,目录建到哪,没有多余目录,也没有遗漏业务。

第三步:对应【全局布局模块】——新建layout目录

后台管理系统有统一的页面外壳(侧边栏+顶部导航+内容区域),这是独立于具体业务页面的全局公共布局,不属于任何一个业务页面,所以单独新建目录:

src/
├── views/  # 业务页面
├── layout/ # 核心:全局布局容器,对应【全局布局模块】
   └── index.vue # 布局主组件,承载所有业务页面展示

新建逻辑&业务对应

1. 独立拆分公共布局,和业务页面解耦,不用在每个页面重复写布局代码

2. 后续所有 views 下的业务页面,都作为子页面嵌入 layout ,实现布局复用

3. 只做布局渲染、菜单切换、路由承载,不写具体业务逻辑

第四步:对应【页面跳转&权限控制】——新建router目录

业务页面、全局布局都有了,页面之间需要跳转、需要控制访问权限(未登录不能进后台),这部分路由逻辑是独立的,不属于任何页面,因此新建路由专属目录:

src/
├── views/
├── layout/
├── router/ # 核心:路由管理,负责页面跳转、权限校验
   └── index.js # 路由配置主文件

新建逻辑&业务对应

1. 对应所有页面的跳转规则,把 login/home/books/profile 页面路由统一配置

2. 承载登录权限校验逻辑,实现未登录跳转登录页的权限控制

3. 路由逻辑集中管理,不分散在各个页面中,方便后期维护修改

第五步:对应【全局数据共享】——新建store目录

后台系统存在跨页面共享数据:用户登录信息、token、用户权限等,这些数据在登录页、首页、个人中心、图书管理页都会用到,需要独立的全局状态管理,因此新建Pinia状态管理目录:

src/
├── views/
├── layout/
├── router/
├── store/ # 核心:全局状态管理,存储跨页面共享数据
   ├── modules/ # 按业务拆分状态模块
      └── user.js # 用户状态:对应登录模块、个人中心模块数据
   └── index.js # Pinia入口配置文件

新建逻辑&业务对应

1. 对应权限登录、个人中心模块的共享数据,专门管理用户信息、登录状态

2. 按业务模块拆分状态文件,后续如果需要图书相关全局状态,直接在 modules 下新建 book.js 即可

3. 状态与页面分离,避免组件间层层传值,降低代码耦合

第六步:对应【后端接口交互】——新建api目录

所有业务页面都需要和后端对接接口(登录校验、图书增删改查、用户信息修改),如果把接口代码写在页面里,后期接口修改要逐个页面改,极其混乱,因此单独新建接口管理目录:

src/
├── views/
├── layout/
├── router/
├── store/
├── api/ # 核心:所有后端接口请求容器

新建逻辑&业务对应

1. 对应所有业务模块的接口请求,后续按业务新建接口文件: user.js (登录/个人中心接口)、 book.js (图书管理接口)

2. 接口与页面业务分离,统一管理请求地址、请求参数、响应数据

3. 接口修改只改当前文件,不影响页面业务代码

第七步:对应【通用工具封装】——新建utils目录

项目中有很多和业务无关、可复用的工具逻辑(最核心的就是接口请求封装),不需要在每个页面重复编写,因此新建工具函数目录:

src/
├── views/
├── layout/
├── router/
├── store/
├── api/
├── utils/ # 核心:通用工具函数封装
   └── request.js # 核心:axios请求封装

新建逻辑&业务对应

1. 承载全局通用工具代码, request.js 专门封装axios,统一处理请求头、响应报错、token携带

2. 后续可新增格式化、校验类工具,所有业务页面均可复用

3. 通用逻辑抽离,让业务页面只关注业务实现

第八步:对应【公共组件&静态资源】——补全剩余目录

最后,把项目中会用到的公共组件、静态资源补充完整,完成整个目录搭建,整体看一下:

src/
├── views/ # 业务页面
├── layout/ # 全局布局
├── router/ # 路由
├── store/ # 状态管理
├── api/ # 接口请求
├── utils/ # 工具函数
├── components/ # 全局公共组件(表格、弹窗、搜索框等)
├── assets/ # 静态资源(图片、全局样式、图标)
├── App.vue # 项目根组件
├── main.js # 项目入口文件
└── style.css # 全局样式

逐模块建目录的核心意义

拆分一个业务模块,新建一个对应目录,这样搭建的目录结构,核心优势在于:

1. 每一个目录都有明确业务归属,没有无意义的文件夹,清晰看懂每个目录的作用

2. 完全贴合业务拆分逻辑,业务和目录一一对应,后期新增、修改、删除业务,只需要操作对应目录

3. 代码职责完全分离,页面、路由、状态、接口、工具各司其职,项目再大也不会混乱

4. 循序渐进搭建,符合学习和开发逻辑,不会一上来被复杂目录劝退,每一步都知道自己在做什么、为什么这么做

项目目录骨架已经按照业务需求完整搭建完毕,文件夹层级清晰、职责划分明确,整个项目的基础框架已然成型。但此时我们还不能急于动手编写路由、接口封装、全局状态这些功能模块代码,在正式开启所有功能手写工作前,有一个至关重要、必须优先完成的环节——项目全局配置

全局配置落地:vite.config.js 核心工程环境搭建

我们所说的配置文件,就是项目根目录下的vite.config.js它是整个Vue3+Vite项目的核心工程配置文件,不涉及任何业务逻辑,却掌管着项目的编译规则、插件调用、路径映射、代码导入规范等所有底层运行逻辑。

之所以要提前做这项配置,核心原因有两点

:提前规范项目开发规则,统一路径别名、自动导入API与组件,省去后续重复手写引入代码的繁琐,提升开发效率;

:提前配置好项目打包、运行的基础环境,规避后续开发中路径报错、组件无法识别、打包部署失败等问题,为所有功能代码编写筑牢底层环境基础,让后续开发更顺畅、代码更规范。

接下来我们就一步步完成本项目vite.config.js的完整配置

1. 导入所有需要用到的配置依赖

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'
import { fileURLToPath,URL } from 'node:url'

这一部分主要是引入我们接下来要使用的各类插件和工具:

  •  defineConfig :Vite 官方配置方法,用来规范配置格式,拥有更好的代码提示
  •  vue :Vue编译插件,让项目可以识别并解析  .vue  文件
  •  AutoImport :自动导入工具
  •  Components :组件自动按需导入工具
  • 最后两个Node 自带方法,专门用来处理文件路径

2. 插件功能配置

plugins: [
  vue(),
  AutoImport({
    imports:['vue','vue-router','pinia'],
    dts:true
  }),
  Components({
    resolvers:[ElementPlusResolver()],
    dts:true
  })
]

这是配置文件里最核心的功能区域

1. 注册vue插件,保证项目正常运行Vue语法

2. AutoImport 自动导入 自动帮我们引入 vue、vue-router、pinia 里的常用API。 后续开发不用每次手动 import,直接使用语法即可,代码更加简洁干净。

3. Components 组件自动引入 配合 ElementPlus 解析器,实现UI组件按需自动引入。 不需要全局引入整个组件库,用到什么加载什么,项目体积更小。  dts:true  开启类型提示,避免代码爆红报错。

3. 打包路径配置

base:'./'

专门配置项目打包之后的资源访问路径。 使用相对路径,可以避免项目打包部署后出现页面空白、样式丢失、资源加载失败,是后台管理系统必备配置。

4. 路径别名配置

resolve:{
  alias:{
    '@': fileURLToPath(new URL('./src', import.meta.url))
  }
}

将符号  @  直接映射指向我们的  src  源代码根目录。

(适配我们前面规划好的整套目录结构,之后引入文件可以直接简写  @/router   @/utils   @/views  路径直观、优雅,不会出现复杂冗长的层级跳转。)

整合配置代码

// 引入Vite配置方法,提供类型提示
import { defineConfig } from 'vite'
// 引入Vue编译插件,让Vite支持.vue文件
import vue from '@vitejs/plugin-vue'
// 引入API自动导入插件
import AutoImport from 'unplugin-auto-import/vite'
// 引入组件自动导入插件
import Components from 'unplugin-vue-components/vite'
// 引入ElementPlus组件解析器,实现按需自动引入
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// 引入Node路径处理方法,用于配置路径别名
import { fileURLToPath, URL } from 'node:url'

// Vite配置导出
export default defineConfig({
  // 项目插件配置
  plugins: [
    // 启用Vue编译功能
    vue(),
    // API自动导入配置
    AutoImport({
      // 自动导入Vue、VueRouter、Pinia的核心API,无需手动import
      imports: ['vue', 'vue-router', 'pinia'],
      // 自动生成类型声明文件,避免代码报错
      dts: true
    }),
    // 组件自动导入配置
    Components({
      // 自动解析并导入ElementPlus组件
      resolvers: [ElementPlusResolver],
      // 自动生成组件类型声明文件
      dts: true
    })
  ],
  // 打包资源使用相对路径,防止部署后资源加载失败
  base: './',
  // 路径别名配置
  resolve: {
    alias: {
      // 将@映射为src根目录,简化文件引入路径
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

到这里,项目根配置文件基本完成。工程环境全部搭建就绪,接下来我们就可以依次开始搭建项目底层三件套:路由基础配置、Axios请求封装、Pinia全局状态雏形

项目底层基础架构三件套

在项目全局配置完成之后,我们正式搭建项目三大底层基础模块:路由 Router、网络请求 Axios、全局状态 Pinia

这三个模块是整个后台管理系统的运行根基。路由负责页面跳转,Axios负责后端接口请求,Pinia负责全局数据共享。底层架构搭建完成,方便后续所有页面业务更好开发。

同时绝大多数 Vue3 后台管理项目,这三份初始化基础代码写法基本固定,属于通用架构模板。我们目前只搭建最简雏形结构,不写入业务逻辑,后续开发页面再逐步扩充。

1. 路由配置

文件路径: src/router/index.js

// 引入创建路由、路由模式核心方法
import { createRouter, createWebHistory } from 'vue-router'

// 路由配置数组,存放所有页面路由信息
const routes = []

// 创建路由实例
const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

关键词解释

  •  createRouter :用于创建路由实例,是路由功能的核心方法
  •  createWebHistory :开启 history 路由模式,地址不带 # 号
  •  routes :路由规则数组,所有页面路径与组件都配置在这里

(整体说明:路由结构简单单层文件,一个文件完成所有路由初始化,结构直观清晰。)

2. Axios 请求封装

文件路径: src/utils/request.js 

// 引入axios请求库
import axios from 'axios'

// 创建独立axios实例
const service = axios.create({
  baseURL: '',
  timeout: 10000
})

// 请求拦截器
service.interceptors.request.use(config => {
  return config
})

// 响应拦截器
service.interceptors.response.use(
  res => res.data,
  err => Promise.reject(err)
)

export default service

关键词解释

  •  axios.create :创建独立请求实例,统一管理接口配置
  •  baseURL :接口公共基础地址
  •  interceptors :拦截器,统一处理请求头、返回数据、错误信息

(整体说明:同样为单文件结构,一个文件完成请求封装,所有接口统一走当前实例,方便统一维护。)

3. Pinia 全局状态管理

文件目录结构

stores
├─ index.js        // pinia 总入口
└─ modules
   └─ user.js      // 具体业务状态模块
① Pinia 根实例

***路径: src/stores/index.js ***

// 引入创建pinia大仓库方法
import { createPinia } from "pinia";

// 创建全局唯一状态管理容器
const pinia = createPinia()

export default pinia
② 用户状态模块

路径: src/stores/modules/user.js 

// 定义单独业务仓库
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: {}
  }),
  actions: {}
})

这个项目里Pinia 和路由、Axios结构区别

1. 路由、Axios 都是单层单文件结构 一个文件夹内只有一个 index.js,功能集中、结构简单。 2. Pinia 采用双层模块化结构

  •  index.js :只创建全局根仓库,做统一入口
  •  modules :拆分不同业务状态仓库,用户、权限、菜单分开管理

(这种分包方式扩展性更强,项目变大后不会代码臃肿)

入口文件(main.js)全局插件挂载

三大底层基础模块已全部搭建完成,路由、请求、全局状态的核心架构已然成型,但这些独立的配置和工具,还无法直接在Vue项目中全局生效。

我们需要通过项目唯一入口文件main.js,将路由、Pinia以及项目用到的ElementPlus组件库、全局样式,统一挂载到Vue根实例上,完成最后一步全局注册,让所有底层配置和第三方插件贯穿整个项目,至此整套项目基础架构才算彻底闭环。

main.js全局挂载配置

文件路径: src/main.js

import { createApp } from 'vue'
import App from './App.vue'
// 引入路由实例
import router from './router'
// 引入Pinia全局根仓库
import pinia from './store'
// 引入ElementPlus组件库
import ElementPlus from 'element-plus'
// 引入ElementPlus默认样式
import 'element-plus/dist/index.css'
// 引入项目全局自定义样式
import '@/assets/styles/global.scss'

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

// 全局挂载路由
app.use(router)
// 全局挂载Pinia状态管理
app.use(pinia)
// 全局挂载ElementPlus组件库
app.use(ElementPlus)

// 将Vue实例挂载到页面DOM节点,启动项目
app.mount('#app')

代码说明

  • 依次引入路由、Pinia、ElementPlus及全局样式,将独立模块统一汇总到入口文件

  • 通过 app.use() 完成全局挂载,挂载后整个项目所有页面都能直接使用对应功能

  • 最后 app.mount('#app') 是项目渲染的关键,将Vue应用挂载到页面指定节点,项目正式运行

至此,从Vite工程化配置,到三大底层模块搭建,再到入口文件全局挂载,Vue3后台管理系统全套基础架构全部搭建完成,没有遗漏任何核心配置,后续可以毫无阻碍地进入页面开发、业务逻辑编写阶段。

项目业务逻辑代码编写与逐步完善

在完成项目目录搭建、工程基础配置、网络请求封装、路由配置、Pinia状态管理、全局组件库挂载等底层基础工程代码后,项目已具备正常启动运行条件。

底层通用基建全部落地完毕,正式进入页面业务代码开发阶段。

整体业务开发也要遵循由大框架到页面、由基础交互到完整业务、由单一功能到整体闭环的前端工程开发思路,不会一次性完成所有业务代码编写,按照开发顺序分步书写、迭代优化、逐步补全逻辑。

结合当前项目真实目录结构与代码文件,整体业务代码编写顺序以及对应文件大致思路如下:

1. 搭建后台管理系统整体布局骨架 对应文件: src/layout/index.vue

2. 开发登录页面结构、表单校验、登录请求业务逻辑 对应文件: src/views/login/index.vue

3. 完善路由守卫,实现登录权限控制与页面访问拦截 对应文件: src/router/index.js 

4. 维护用户登录状态,完善全局用户状态管理 对应文件: src/store/modules/user.js

5. 在布局页面内部完成侧边菜单渲染,实现菜单与路由联动 对应文件: src/layout/index.vue 

6. 依次开发各个核心业务页面

  • 图书管理页面: src/views/books/index.vue 
  • 首页数据统计: src/views/home/index.vue
  • 个人中心页面: src/views/profile/index.vue

7. 整体功能调试、业务逻辑补全、页面交互完善

大致就是这个由大及小,由外及内的编写顺序,现在直接开始

整体布局页面(src/layout/index.vue)

首先展示本页面最终视觉完成效果进行对照(只看布局)

屏幕截图 2026-04-29 112607.png

一、现阶段编写

遵循结构样式先行、依赖逻辑后置的开发思路,本阶段优先完成页面可视化架构与样式美化,所有依赖其他业务模块、暂无法独立实现的功能逻辑,全部预留位置,后续补齐业务闭环后再添加。

二、本阶段可完整实现的内容

1. 页面整体架构搭建

直接确定后台管理系统经典布局,划分左侧侧边栏、顶部头部、主体内容区三大核心板块,搭建完整DOM结构,引入Element Plus菜单组件,配置菜单路由跳转、菜单图标,完成基础导航框架搭建。

2. 页面样式完善

一次性完成所有样式代码编写,包括侧边栏渐变背景、logo样式、菜单圆角与选中效果、顶部头部排版、内容区布局等,实现页面完整视觉效果,无需后续反复修改样式。

三、本阶段暂不实现、后续补充的功能逻辑

以下功能均依赖其他业务模块,当前无对应支撑逻辑,无法独立完成,待后续对应模块开发完毕后,再回补到布局页面中:

1. 菜单自动高亮(activeMenu):依赖路由路径匹配,需路由完整配置后实现

2. 页面标题展示(currentTitle):依赖路由meta元信息配置,需完善路由后添加

3. 用户信息展示:依赖Pinia用户状态仓库、登录业务逻辑,需完成登录模块后接入

4. 退出登录功能(handleLogout):依赖用户状态清空、路由跳转,需登录状态逻辑完善后实现

四、本阶段编写代码

<template>
  <div class="layout">
    <aside class="sidebar">
      <div class="logo">Admin sys</div>
      <!-- 菜单基础结构+路由+图标,本阶段直接完成 -->
      <el-menu
        router
        background-color="transparent"
        text-color="#cfe3ff"
        active-text-color="#ffffff"
      >
        <el-menu-item index="/home">
          <el-icon><House /></el-icon>
          <span>首页</span>
        </el-menu-item>
        <el-menu-item index="/books">
          <el-icon><Reading /></el-icon>
          <span>图书管理</span>
        </el-menu-item>
        <el-menu-item index="/profile">
          <el-icon><User /></el-icon>
          <span>个人中心</span>
        </el-menu-item>
      </el-menu>
    </aside>

    <section class="main">
      <!-- 头部仅搭建结构,用户信息、退出按钮暂不写逻辑 -->
      <header class="header page-card">
        <div class="crumb"></div>
        <div class="header-right"></div>
      </header>

      <!-- 路由容器,本阶段直接完成 -->
      <main class="content">
        <router-view />
      </main>
    </section>
  </div>
</template>

<script setup>
// 仅引入当前阶段必需的图标
import { House, Reading, User } from '@element-plus/icons-vue'
</script>

<!-- 所有样式本阶段一次性完善 -->
<style scoped lang="scss">
.layout {
  display: flex;
  width: 100%;
  height: 100%;
}

.sidebar {
  width: 220px;
  padding: 20px 14px;
  background: linear-gradient(180deg, #72a8f7 0%, #4f84d4 100%);
}

.logo {
  height: 52px;
  margin-bottom: 16px;
  color: #fff;
  font-size: 24px;
  font-weight: 700;
  line-height: 52px;
  text-align: center;
  letter-spacing: 1px;
  background: rgba(255, 255, 255, 0.12);
  border-radius: 12px;
}

.menu {
  border-right: none;
}

:deep(.el-menu-item) {
  margin: 6px 0;
  border-radius: 10px;
}

:deep(.el-menu-item.is-active) {
  background: rgba(255, 255, 255, 0.2);
}

.main {
  display: flex;
  flex: 1;
  flex-direction: column;
  padding: 18px 18px 16px;
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 64px;
  padding: 0 18px;
}

.crumb {
  color: #2f5b96;
  font-size: 18px;
  font-weight: 700;
}

.header-right {
  display: flex;
  gap: 12px;
  align-items: center;
}

.content {
  flex: 1;
  padding-top: 16px;
  overflow: auto;
}
</style>

当前代码已大致实现布局页面完整结构与视觉样式

登录页面(src/views/login/index.vue)

首先展示本页面最终完成效果图,直观呈现页面整体样式与布局结构。

屏幕截图 2026-04-29 112842.png

一、现阶段编写

延续结构样式先行、依赖逻辑后置的开发思路,本阶段优先完成页面整体布局结构与全部样式代码。凡是依赖全局状态、路由跳转、登录业务交互的逻辑代码全部暂时移除,等待后续模块开发完毕后统一补充完善。

二、本阶段可完整实现的内容

1. 页面整体架构搭建 根据最终页面结构,搭建登录容器、登录卡片、表单整体结构,引入对应图标与表单组件,完成页面基础DOM结构搭建。

2. 页面样式完善 直接沿用项目完整样式代码,保留全部背景、卡片圆角、配色、排版布局,页面视觉效果和最终成品完全一致,无需二次修改美化。

三、本阶段暂不实现、后续补充的功能逻辑

当前阶段路由、用户仓库、登录业务还未开发完成,以下交互逻辑暂时不编写:

1. 表单双向数据绑定

2. 表单校验规则

3. 登录点击事件、账号判断逻辑

4. 登录成功保存用户信息

5. 登录完成页面跳转

四、本阶段代码

<template>
  <div class="login-box">
    <div class="login-card">
      <h2>图书后台管理系统</h2>
      <p class="sub-title">图书管理</p>
      <el-form>
        <el-form-item label="用户名">
          <el-input placeholder="请输入用户名"></el-input>
        </el-form-item>
        <el-form-item label="密码">
          <el-input type="password" placeholder="请输入密码"></el-input>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" class="login-btn">登录</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script setup>
</script>

<style scoped lang="scss">
.login-box {
  width: 100vw;
  height: 100vh;
  background: linear-gradient(120deg, #74a9f8, #5287d8);
  display: flex;
  justify-content: center;
  align-items: center;
}

.login-card {
  width: 420px;
  padding: 40px 36px;
  background: #fff;
  border-radius: 14px;
  box-shadow: 0 6px 22px rgba(0,0,0,0.12);

  h2 {
    text-align: center;
    margin-bottom: 30px;
    color: #335894;
    font-weight: bold;
  }
}

.login-btn {
  width: 100%;
}
</style>

当前已经完整实现登录页面布局结构与全部外观样式

路由配置文件(src/router/index.js)

先搭建基础路由骨架、页面路径配置、布局嵌套关系。

本阶段可完整实现的内容

1. 路由基础环境搭建 导入vue-router相关方法,创建路由实例,配置路由模式。

2. 页面路由映射 把已经写完的登录页、布局主页、首页、图书管理、个人中心全部配置对应访问路径。

3. 嵌套路由结构搭建 配置layout布局嵌套子路由结构,实现后台系统标准页面层级关系。

后续补充的功能逻辑

1. 全局路由守卫 beforeEach 登录权限判断

2. 未登录拦截、强制跳转登录页逻辑

3. 登录后放行访问内部页面逻辑

4. 路由重定向细节优化

本阶段代码

import { createRouter, createWebHistory } from 'vue-router'

// 引入页面组件
import Login from '@/views/login/index.vue'
import Layout from '@/layout/index.vue'

const routes = [
  {
    path: '/login',
    component: Login
  },
  {
    path: '/',
    component: Layout,
    redirect: '/home',
    children: [
      {
        path: '/home',
        name: 'home',
        component: () => import('@/views/home/index.vue')
      },
      {
        path: '/books',
        name: 'books',
        component: () => import('@/views/books/index.vue')
      },
      {
        path: '/profile',
        name: 'profile',
        component: () => import('@/views/profile/index.vue')
      }
    ]
  }
]

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

export default router

当前完成项目全部页面路由地址配置与嵌套结构

用户状态管理仓库(src/store/modules/user.js)

现阶段只搭建Pinia仓库基础结构、定义存储数据字段、创建仓库实例。登录信息存取、状态持久化、退出清空数据等交互逻辑暂时不实现,等待前面登录页面业务完善后再补充写入。

本阶段可完整实现的内容

1. 导入Pinia核心方法,创建独立用户仓库

2. 定义仓库内部state状态数据,预留用户名、登录状态等字段

3. 规范仓库导出结构,保证可以在任意页面引入使用

后续补充的功能逻辑

1. 登录后保存用户信息方法

2. 退出登录清空用户数据

3. 本地存储持久化用户登录状态

4. 和登录页面、路由守卫联动调用

本阶段代码

import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => {
    return {
      username: '',
      isLogin: false
    }
  },
  actions: {}
})

目前完成用户仓库整体架构搭建,基础数据字段齐全,仓库可以正常引入使用。

首页页面(src/views/home/index.vue)

首先展示本页面最终完成效果图,直观呈现页面整体样式与布局结构。

屏幕截图 2026-04-29 112650.png

首页页面结构简单,无复杂业务逻辑与交互功能,仅展示基础数据统计卡片与系统文字介绍,整体以静态页面展示为主所以可以直接完善写出来。

<template>
  <div class="home-page">
    <div class="card-grid">
      <div v-for="item in statCards" :key="item.title" class="stat-card page-card">
        <div class="stat-title">{{ item.title }}</div>
        <div class="stat-value">{{ item.value }}</div>
        <div class="stat-foot">{{ item.tip }}</div>
      </div>
    </div>
    <div class="welcome page-card">
      <h3>系统概览</h3>
      <p>本后台包含登录鉴权、路由守卫、数据统计、图书管理 CRUD、搜索筛选与分页等标准企业基础功能。</p >
    </div>
  </div>
</template>

<script setup>
const statCards = [
  { title: '图书总数', value: 1286, tip: '较昨日 +24' },
  { title: '在库图书', value: 1088, tip: '库存健康' },
  { title: '借阅中', value: 172, tip: '借阅率 13.4%' },
  { title: '本月新增', value: 96, tip: '目标达成 82%' }
]
</script>

<style scoped lang="scss">
.card-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(220px, 1fr));
  gap: 16px;
}

.stat-card {
  padding: 18px;
}

.stat-title {
  color: #6f8eb8;
  font-size: 14px;
}

.stat-value {
  margin-top: 10px;
  color: #2f5b96;
  font-size: 30px;
  font-weight: 700;
}

.stat-foot {
  margin-top: 14px;
  color: #87a2c7;
  font-size: 12px;
}

.welcome {
  margin-top: 16px;
  padding: 18px;
  color: #4f6f9d;
  line-height: 1.8;
}

.welcome h3 {
  margin: 0 0 10px;
  color: #2f5b96;
}

.welcome p {
  margin: 0;
}

.welcome p + p {
  margin-top: 8px;
}
</style>

该页面只做页面渲染展示,不存在数据修改、接口请求、业务处理逻辑,页面简洁直观,完成首页基础展示效果。

图书管理页面(src/views/books/index.vue)

首先展示本页面最终完成效果图,直观呈现页面整体样式与布局结构。

屏幕截图 2026-04-29 112730.png

依旧遵循页面结构与样式优先,业务逻辑后置补齐的开发方式。 只搭建表格整体结构、页面布局、完整美化样式。表格增删改查、数据渲染、接口请求、操作事件全部暂时不编写。

本阶段可完整实现的内容

1. 搭建图书管理页面整体布局,顶部操作栏、表格主体结构

2. 引入表格、按钮等组件,完成页面完整DOM结构

3. 保留项目原版全部样式,页面外观和最终成品保持一致

后续补充的功能逻辑

1. 图书列表数据获取、表格数据渲染

2. 新增、编辑、删除图书操作事件

3. 搜索筛选功能

4. 所有表格业务交互逻辑

本阶段代码

<template>
  <div class="books-page">
    <!-- 顶部搜索区域 -->
    <div class="search-panel page-card">
      <el-form :inline="true">
        <el-form-item label="书名">
          <el-input />
        </el-form-item>
        <el-form-item label="状态">
          <el-select>
            <el-option label="在库" />
            <el-option label="借出" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary">查询</el-button>
          <el-button>重置</el-button>
          <el-button type="success">新增图书</el-button>
        </el-form-item>
      </el-form>
    </div>

    <!-- 表格区域 -->
    <div class="table-panel page-card">
      <el-table stripe>
        <el-table-column label="书名" />
        <el-table-column label="作者" />
        <el-table-column label="分类" />
        <el-table-column label="价格" />
        <el-table-column label="状态" />
        <el-table-column label="创建时间" />
        <el-table-column label="操作" fixed="right">
          <template #default>
            <el-button link type="primary">编辑</el-button>
            <el-button link type="danger">删除</el-button>
          </template>
        </el-table-column>
      </el-table>

      <div class="pager">
        <el-pagination />
      </div>
    </div>

    <!-- 新增编辑弹窗 -->
    <el-dialog title="图书信息" width="520px">
      <el-form label-width="80px">
        <el-form-item label="书名">
          <el-input />
        </el-form-item>
        <el-form-item label="作者">
          <el-input />
        </el-form-item>
        <el-form-item label="分类">
          <el-input />
        </el-form-item>
        <el-form-item label="价格">
          <el-input-number />
        </el-form-item>
        <el-form-item label="状态">
          <el-radio-group>
            <el-radio label="在库" />
            <el-radio label="借出" />
          </el-radio-group>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button>取消</el-button>
        <el-button type="primary">确认</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
// 本阶段只搭建页面结构,暂不编写任何业务逻辑、数据、方法
</script>

<style scoped lang="scss">
.books-page {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.search-panel,
.table-panel {
  padding: 16px;
}

.pager {
  display: flex;
  justify-content: flex-end;
  margin-top: 16px;
}
</style>

图书管理页面整体布局、组件结构、页面样式已有。

个人中心页面(src/views/profile/index.vue)

首先展示本页面最终完成效果图,直观呈现页面整体样式与布局结构。

屏幕截图 2026-04-29 112756.png

继续沿用整体开发思路,优先完成页面整体结构搭建与全部样式美化,只完成静态页面展示。 用户信息回显、信息修改、数据提交、个人资料业务逻辑全部后置,后续统一集中补充。

本阶段实现的内容

1. 搭建个人中心页面布局结构,卡片排版、信息展示区域

2. 完成表单结构、页面整体布局

3. 保留原版全部样式代码,页面视觉效果和最终成品一致

后续补充的功能逻辑

1. 用户信息数据回填展示

2. 资料修改、表单提交逻辑

3. 信息更新相关业务交互

本阶段代码

<template>
  <div class="profile-page page-card">
    <div class="avatar-section">
      <el-avatar :size="96">

      </el-avatar>
      <div class="avatar-actions">
        <div class="avatar-title">用户头像</div>
        <el-input placeholder="请输入头像图片链接(可选)" clearable style="width: 320px" />
        <div class="avatar-tip">不填写时将显示用户名首字母。</div>
      </div>
    </div>

    <el-divider />

    <el-form label-width="90px" class="profile-form">
      <el-form-item label="用户名">
        <el-input readonly />
      </el-form-item>
      <el-form-item label="昵称">
        <el-input placeholder="请输入昵称" />
      </el-form-item>
      <el-form-item label="邮箱">
        <el-input placeholder="请输入邮箱" />
      </el-form-item>
      <el-form-item label="手机号">
        <el-input placeholder="请输入手机号" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary">保存修改</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
// 本阶段仅搭建页面布局结构,暂不编写数据绑定、表单校验、保存逻辑
</script>

<style scoped lang="scss">
.profile-page {
  padding: 20px;
}

.avatar-section {
  display: flex;
  gap: 16px;
  align-items: center;
}

.avatar-actions {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.avatar-title {
  color: #2f5b96;
  font-size: 15px;
  font-weight: 600;
}

.avatar-tip {
  color: #87a2c7;
  font-size: 12px;
}

.profile-form {
  max-width: 560px;
}
</style>

至此,项目所有页面、路由、状态仓库基础骨架全部开发完毕。

接下来进入文章最后一大环节:统一回填所有业务逻辑、联动功能、页面交互,把之前所有搁置的逻辑全部补齐,项目正式完整闭环。

Layout布局页面 业务逻辑回填

一、template 模板部分改动

页面整体布局、侧边栏、菜单、路由容器、外层结构全部保留不变 只在头部 header-right 区域新增用户信息展示、退出登录按钮、绑定事件

1.头部右侧区域结构扩充

原有空标签  

<div class="header-right"></div>

修改回填后

<!-- 展示当前登录用户名 -->
<span class="username">{{ userInfo.nickname }}</span>
<!-- 退出登录点击事件 -->
<el-button type="text" icon="Logout" @click="handleLogout">退出登录</el-button>
  • 侧边菜单:结构完全不动,保留原有路由跳转
  • 路由容器 router-view:无任何修改
  • 仅页面头部右上角新增用户名称展示、退出按钮、点击退出事件

二,script 脚本逻辑

模块1:新增图标依赖导入

引入退出图标

import { Logout } from '@element-plus/icons-vue'
模块2:引入路由、用户仓库全局状态
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/modules/user'

作用: 获取路由实例、获取全局登录用户信息、操作用户登录状态

模块3:实例声明与用户信息获取
const router = useRouter()
const userStore = useUserStore()
// 从全局仓库获取当前登录用户信息
const userInfo = userStore.userInfo
模块4:核心退出登录业务方法
const handleLogout = () => {
  // 清空本地用户登录信息
  userStore.clearUserInfo()
  // 跳转回登录页面
  router.push('/login')
}

逻辑流程: 点击退出 → 清空用户登录数据 → 页面跳转至登录页

三、逻辑回填完成 · Layout完整最终代码

<template>
  <div class="layout">
    <aside class="sidebar">
      <div class="logo">Admin sys</div>
      <el-menu
        :default-active="activeMenu"
        class="menu"
        router
        background-color="transparent"
        text-color="#cfe3ff"
        active-text-color="#ffffff"
      >
        <el-menu-item index="/home">
          <el-icon><House /></el-icon>
          <span>首页</span>
        </el-menu-item>
        <el-menu-item index="/books">
          <el-icon><Reading /></el-icon>
          <span>图书管理</span>
        </el-menu-item>
        <el-menu-item index="/profile">
          <el-icon><User /></el-icon>
          <span>个人中心</span>
        </el-menu-item>
      </el-menu>
    </aside>

    <section class="main">
      <header class="header page-card">
        <div class="crumb">{{ currentTitle }}</div>
        <div class="header-right">
          <span class="welcome">你好,{{ userStore.userInfo.username || '管理员' }}</span>
          <el-button type="primary" plain @click="handleLogout">退出登录</el-button>
        </div>
      </header>

      <main class="content">
        <router-view />
      </main>
    </section>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { House, Reading, User } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/modules/user'

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

const activeMenu = computed(() => route.path)
const currentTitle = computed(() => route.meta.title || '后台管理')

const handleLogout = async () => {
  try {
    await ElMessageBox.confirm('确认退出当前账号吗?', '提示', {
      type: 'warning'
    })
    userStore.logout()
    ElMessage.success('已退出登录')
    router.push('/login')
  } catch {
    // 用户取消退出时保持当前页面
  }
}
</script>

<style scoped lang="scss">
.layout {
  display: flex;
  width: 100%;
  height: 100%;
}

.sidebar {
  width: 220px;
  padding: 20px 14px;
  background: linear-gradient(180deg, #72a8f7 0%, #4f84d4 100%);
}

.logo {
  height: 52px;
  margin-bottom: 16px;
  color: #fff;
  font-size: 24px;
  font-weight: 700;
  line-height: 52px;
  text-align: center;
  letter-spacing: 1px;
  background: rgba(255, 255, 255, 0.12);
  border-radius: 12px;
}

.menu {
  border-right: none;
}

:deep(.el-menu-item) {
  margin: 6px 0;
  border-radius: 10px;
}

:deep(.el-menu-item.is-active) {
  background: rgba(255, 255, 255, 0.2);
}

.main {
  display: flex;
  flex: 1;
  flex-direction: column;
  padding: 18px 18px 16px;
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 64px;
  padding: 0 18px;
}

.crumb {
  color: #2f5b96;
  font-size: 18px;
  font-weight: 700;
}

.header-right {
  display: flex;
  gap: 12px;
  align-items: center;
}

.welcome {
  color: #5578a8;
  font-size: 14px;
}

.content {
  flex: 1;
  padding-top: 16px;
  overflow: auto;
}
</style>

登录页面业务逻辑回填

一、template 模板部分改动

整体 HTML 结构、标签、布局、文字完全不删除、不修改 只新增绑定属性与点击事件,具体改动如下:

1. el-form 表单标签

新增表单实例、数据双向绑定、表单校验规则

<!-- 新增 ref表单实例  :model数据绑定  :rules校验规则 -->
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules">
2. 用户名输入框

新增数据双向绑定

<!-- 新增 v-model 绑定表单数据 -->
<el-input v-model="loginForm.username" placeholder="请输入用户名"></el-input>
3. 密码输入框

新增数据双向绑定

<!-- 新增 v-model 绑定表单数据 -->
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码"></el-input>
4. 登录按钮

新增点击登录触发事件

<!-- 新增 v-model 绑定表单数据 -->
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码"></el-input>

二、script 脚本逻辑

模块1:引入项目依赖

导入vue工具、提示组件、路由、用户状态管理仓库

import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/modules/user'

作用:提供页面跳转、消息提示、全局用户信息管理能力

模块2:创建基础实例对象
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/modules/user'

作用:

  • router:控制页面路由跳转
  • userStore:操作全局用户登录信息
  • loginFormRef:获取表单DOM,用于表单校验
模块3:定义登录表单数据与校验规则
// 登录表单双向绑定数据
const loginForm = reactive({
  username: '',
  password: ''
})

// 表单非空校验
const loginRules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}

作用:接收用户输入账号密码,判断输入内容是否为空

模块4:核心登录业务方法
const handleLogin = async () => {
  // 1.执行表单校验
  await loginFormRef.value.validate()

  // 2.判断账号密码是否正确
  if (loginForm.username === 'admin' && loginForm.password === '123456') {
    // 3.登录成功,保存用户信息
    userStore.setUserInfo({
      username: 'admin',
      nickname: '管理员'
    })
    ElMessage.success('登录成功')
    // 4.跳转到系统首页
    router.push('/home')
  } else {
    // 5.账号错误提示
    ElMessage.error('用户名或密码错误')
  }
}

功能完整流程: 表单校验 → 账号密码判断 → 存储用户信息 → 登录提示 → 页面跳转

三、逻辑回填完成 · 页面完整代码

<template>
  <div class="login-page">
    <div class="login-box page-card">
      <h2 class="title">后台管理系统</h2>
      <p class="sub-title">图书管理</p>
      <el-form
        ref="formRef"
        :model="form"
        :rules="rules"
        label-position="top"
        class="login-form"
      >
        <el-form-item label="账号" prop="username">
          <el-input v-model="form.username" placeholder="请输入账号" clearable />
        </el-form-item>
        <el-form-item label="密码" prop="password">
          <el-input
            v-model="form.password"
            type="password"
            placeholder="请输入密码"
            show-password
            @keyup.enter="handleLogin"
          />
        </el-form-item>
        <el-button class="submit-btn" type="primary" :loading="loading" @click="handleLogin">
          登录
        </el-button>
      </el-form>
      <div class="tips">演示账号:admin | 演示密码:123456</div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/modules/user'

const router = useRouter()
const userStore = useUserStore()
const formRef = ref(null)
const loading = ref(false)

const form = reactive({
  username: 'admin',
  password: '123456'
})

const rules = {
  username: [
    { required: true, message: '请输入账号', trigger: 'blur' },
    { min: 3, max: 20, message: '账号长度 3-20 位', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, max: 20, message: '密码长度 6-20 位', trigger: 'blur' }
  ]
}

const handleLogin = async () => {
  if (!formRef.value) return
  await formRef.value.validate()
  loading.value = true

  setTimeout(() => {
    userStore.login(form)
    loading.value = false
    ElMessage.success('登录成功')
    router.push('/home')
  }, 400)
}
</script>

<style scoped lang="scss">
.login-page {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  background: linear-gradient(145deg, #edf5ff 0%, #dbeaff 100%);
}

.login-box {
  width: 420px;
  padding: 34px 30px 28px;
}

.title {
  margin: 0;
  color: #2f5b96;
  font-size: 28px;
  text-align: center;
}

.sub-title {
  margin: 8px 0 24px;
  color: #6e8ab2;
  font-size: 14px;
  text-align: center;
}

.submit-btn {
  width: 100%;
  margin-top: 4px;
}

.tips {
  margin-top: 16px;
  color: #84a0c5;
  font-size: 12px;
  text-align: center;
}
</style>

图书管理页面 books 业务逻辑回填

一、template 模板改动说明

页面整体三层结构:搜索区域、表格区域、弹窗区域DOM结构完全不变 只新增数据绑定、渲染属性、点击事件、插槽内容、表单校验属性

1. 顶部搜索表单改动
<el-form :inline="true">
  <el-input />
  <el-select>
    <el-option label="在库" />
    <el-option label="借出" />
  </el-select>
  <el-button>查询</el-button>
  <el-button>重置</el-button>
  <el-button>新增图书</el-button>

回填新增内容

  • form 添加  :model="queryForm"  表单数据绑定
  • 输入框、下拉框添加  v-model  双向绑定、提示文字、清空属性
  • option 补充  value  值
  • 三个按钮分别绑定点击查询、重置、打开新增弹窗事件
<el-form :inline="true" :model="queryForm">
  <el-input v-model="queryForm.keyword" placeholder="请输入书名关键字" clearable />
  <el-select v-model="queryForm.status" placeholder="全部状态" clearable style="width: 140px">
    <el-option label="在库" value="in" />
    <el-option label="借出" value="out" />
  </el-select>
  <el-button type="primary" @click="handleSearch">查询</el-button>
  <el-button @click="handleReset">重置</el-button>
  <el-button type="success" @click="openAddDialog">新增图书</el-button>
2. el-table 表格整体改动
  • 表格添加  :data="pagedList"  绑定分页渲染数据
  • 每一列添加  prop  字段,绑定对应图书属性
  • 价格、状态、时间列新增插槽,自定义页面展示格式
  • 操作按钮绑定编辑弹窗、删除数据点击事件
3. 分页组件改动
<el-pagination
  v-model:current-page="pagination.page"
  v-model:page-size="pagination.pageSize"
  :page-sizes="[5, 10, 20]"
  layout="total, sizes, prev, pager, next, jumper"
  :total="filteredList.length"
/>
4. 新增编辑弹窗 dialog 改动
  • 弹窗添加  v-model  显示隐藏控制、动态标题
  • 内部表单添加  ref 、 :model 、 :rules  校验规则
  • 所有表单项添加  v-model  数据绑定、校验prop
  • 底部取消、确认按钮绑定关闭弹窗、提交表单事件

 

二、script 脚本新增

骨架script为空,本次全部逻辑分为 8大功能模块

模块1:导入vue工具与消息组件
import { ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'

作用:提供响应式数据、计算属性、弹窗提示、删除确认弹窗

模块2:初始化图书模拟数据
const defaultBooks = [图书数组数据]
const books = ref(defaultBooks)

作用:存放所有图书列表基础数据,页面表格渲染来源

模块3:查询条件、分页、弹窗、表单基础数据
// 搜索条件
const queryForm = reactive({ keyword: '', status: '' })
// 分页信息
const pagination = reactive({ page: 1, pageSize: 10 })
// 弹窗控制
const dialogVisible = ref(false)
const isEdit = ref(false)
// 图书表单数据
const bookForm = reactive({...})
// 表单校验规则
const bookRules = {...}
模块4:筛选过滤 + 分页计算属性
// 根据关键词、状态筛选图书
const filteredList = computed(()=>{})
// 对筛选后数据进行分页切割
const pagedList = computed(()=>{})

功能:实现模糊搜索、状态筛选、表格分页展示

模块5:搜索与重置方法
const handleSearch = () => {
  pagination.page = 1
}
const handleReset = () => {
  queryForm清空,页码重置
}

作用:点击查询刷新数据,点击重置清空所有搜索条件

模块6:弹窗打开、表单重置逻辑
// 打开新增弹窗
const openAddDialog = ()=>{}
// 打开编辑弹窗,回填当前行数据
const openEditDialog = (row)=>{}
// 清空表单
const resetBookForm = ()=>{}
模块7:删除图书业务逻辑
const handleDelete = async (id) => {
  弹出删除确认
  过滤删除对应id数据
  删除成功提示
}
模块8:新增 / 编辑提交表单逻辑
const handleSubmit = async () => {
  表单校验
  判断是编辑还是新增
  编辑:修改原有数据
  新增:插入新图书、自动生成时间id
  关闭弹窗、提示成功
}
模块9:时间格式化工具方法
const formatDate = (dateTime) => {}

作用:把时间戳格式化成年月日时分秒标准格式

三、回填完成 · 完整最终代码

<template>
  <div class="books-page">
    <!-- 顶部搜索区域 -->
    <div class="search-panel page-card">
      <el-form :inline="true" :model="queryForm">
        <el-form-item label="书名">
          <el-input v-model="queryForm.keyword" placeholder="请输入书名关键字" clearable />
        </el-form-item>
        <el-form-item label="状态">
          <el-select v-model="queryForm.status" placeholder="全部状态" clearable style="width: 140px">
            <el-option label="在库" value="in" />
            <el-option label="借出" value="out" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSearch">查询</el-button>
          <el-button @click="handleReset">重置</el-button>
          <el-button type="success" @click="openAddDialog">新增图书</el-button>
        </el-form-item>
      </el-form>
    </div>

    <!-- 表格区域 -->
    <div class="table-panel page-card">
      <el-table :data="pagedList" stripe>
        <el-table-column prop="name" label="书名" min-width="180" />
        <el-table-column prop="author" label="作者" min-width="140" />
        <el-table-column prop="category" label="分类" min-width="120" />
        <el-table-column prop="price" label="价格" width="100">
          <template #default="{ row }">¥{{ row.price }}</template>
        </el-table-column>
        <el-table-column prop="status" label="状态" width="100">
          <template #default="{ row }">
            <el-tag :type="row.status === 'in' ? 'success' : 'warning'">
              {{ row.status === 'in' ? '在库' : '借出' }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="createdAt" label="创建时间" min-width="160">
          <template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
        </el-table-column>
        <el-table-column label="操作" width="180" fixed="right">
          <template #default="{ row }">
            <el-button link type="primary" @click="openEditDialog(row)">编辑</el-button>
            <el-button link type="danger" @click="handleDelete(row.id)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>

      <div class="pager">
        <el-pagination
          v-model:current-page="pagination.page"
          v-model:page-size="pagination.pageSize"
          :page-sizes="[5, 10, 20]"
          layout="total, sizes, prev, pager, next, jumper"
          :total="filteredList.length"
        />
      </div>
    </div>

    <!-- 新增编辑弹窗 -->
    <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑图书' : '新增图书'" width="520px">
      <el-form ref="bookFormRef" :model="bookForm" :rules="bookRules" label-width="80px">
        <el-form-item label="书名" prop="name">
          <el-input v-model="bookForm.name" placeholder="请输入书名" />
        </el-form-item>
        <el-form-item label="作者" prop="author">
          <el-input v-model="bookForm.author" placeholder="请输入作者" />
        </el-form-item>
        <el-form-item label="分类" prop="category">
          <el-input v-model="bookForm.category" placeholder="请输入分类" />
        </el-form-item>
        <el-form-item label="价格" prop="price">
          <el-input-number v-model="bookForm.price" :min="1" :precision="2" />
        </el-form-item>
        <el-form-item label="状态" prop="status">
          <el-radio-group v-model="bookForm.status">
            <el-radio label="in">在库</el-radio>
            <el-radio label="out">借出</el-radio>
          </el-radio-group>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="handleSubmit">确认</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'

const defaultBooks = [
  { id: 1, name: 'Vue 3 实战进阶', author: '王明', category: '前端', price: 88, status: 'in', createdAt: '2026-04-10 10:20:33' },
  { id: 2, name: 'Node.js 企业开发', author: '张华', category: '后端', price: 79, status: 'out', createdAt: '2026-04-11 11:03:12' },
  { id: 3, name: '数据结构与算法', author: '李雷', category: '基础', price: 65, status: 'in', createdAt: '2026-04-12 08:28:46' },
  { id: 4, name: 'MySQL 性能优化', author: '陈晨', category: '数据库', price: 72, status: 'in', createdAt: '2026-04-12 16:12:05' },
  { id: 5, name: 'TypeScript 从入门到实战', author: '赵阳', category: '前端', price: 92, status: 'out', createdAt: '2026-04-13 09:44:38' },
  { id: 6, name: 'Linux 运维手册', author: '杨帆', category: '运维', price: 69, status: 'in', createdAt: '2026-04-14 14:05:20' },
  { id: 7, name: '微服务架构设计', author: '刘洋', category: '架构', price: 99, status: 'in', createdAt: '2026-04-15 17:20:08' },
  { id: 8, name: 'JavaScript 高级程序设计', author: '周涛', category: '前端', price: 85, status: 'out', createdAt: '2026-04-16 10:10:10' },
  { id: 9, name: 'Python 自动化办公', author: '何琳', category: '工具', price: 58, status: 'in', createdAt: '2026-04-17 13:31:52' },
  { id: 10, name: 'Redis 高并发实战', author: '吴迪', category: '缓存', price: 74, status: 'in', createdAt: '2026-04-18 09:18:26' },
  { id: 11, name: 'Nginx 配置指南', author: '宋佳', category: '运维', price: 66, status: 'out', createdAt: '2026-04-18 18:40:37' },
  { id: 12, name: '前端工程化实践', author: '林北', category: '前端', price: 89, status: 'in', createdAt: '2026-04-19 07:58:41' }
]

const books = ref(defaultBooks)
const queryForm = reactive({ keyword: '', status: '' })
const pagination = reactive({ page: 1, pageSize: 10 })

const dialogVisible = ref(false)
const isEdit = ref(false)
const bookFormRef = ref(null)
const bookForm = reactive({
  id: null,
  name: '',
  author: '',
  category: '',
  price: 1,
  status: 'in'
})

const bookRules = {
  name: [{ required: true, message: '请输入书名', trigger: 'blur' }],
  author: [{ required: true, message: '请输入作者', trigger: 'blur' }],
  category: [{ required: true, message: '请输入分类', trigger: 'blur' }],
  price: [{ required: true, message: '请输入价格', trigger: 'blur' }]
}

const filteredList = computed(() => {
  const keyword = queryForm.keyword.trim().toLowerCase()
  return books.value.filter((item) => {
    const matchedKeyword =
      !keyword ||
      item.name.toLowerCase().includes(keyword) ||
      item.author.toLowerCase().includes(keyword) ||
      item.category.toLowerCase().includes(keyword)
    const matchedStatus = !queryForm.status || item.status === queryForm.status
    return matchedKeyword && matchedStatus
  })
})

const pagedList = computed(() => {
  const start = (pagination.page - 1) * pagination.pageSize
  return filteredList.value.slice(start, start + pagination.pageSize)
})

const handleSearch = () => {
  pagination.page = 1
}

const handleReset = () => {
  queryForm.keyword = ''
  queryForm.status = ''
  pagination.page = 1
}

const resetBookForm = () => {
  bookForm.id = null
  bookForm.name = ''
  bookForm.author = ''
  bookForm.category = ''
  bookForm.price = 1
  bookForm.status = 'in'
}

const openAddDialog = () => {
  isEdit.value = false
  dialogVisible.value = true
  resetBookForm()
}

const openEditDialog = (row) => {
  isEdit.value = true
  dialogVisible.value = true
  Object.assign(bookForm, row)
}

const handleDelete = async (id) => {
  await ElMessageBox.confirm('确认删除这条图书数据吗?', '提示', { type: 'warning' })
  books.value = books.value.filter((item) => item.id !== id)
  ElMessage.success('删除成功')
}

const handleSubmit = async () => {
  if (!bookFormRef.value) return
  await bookFormRef.value.validate()

  if (isEdit.value) {
    books.value = books.value.map((item) =>
      item.id === bookForm.id ? { ...item, ...bookForm } : item
    )
    ElMessage.success('编辑成功')
  } else {
    books.value.unshift({
      ...bookForm,
      id: Date.now(),
      createdAt: new Date().toISOString().replace('T', ' ').slice(0, 19)
    })
    ElMessage.success('新增成功')
  }

  dialogVisible.value = false
  pagination.page = 1
}

const formatDate = (dateTime) => {
  const date = new Date(dateTime)
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  const hour = String(date.getHours()).padStart(2, '0')
  const minute = String(date.getMinutes()).padStart(2, '0')
  return `${year}-${month}-${day} ${hour}:${minute}`
}
</script>

<style scoped lang="scss">
.books-page {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.search-panel,
.table-panel {
  padding: 16px;
}

.pager {
  display: flex;
  justify-content: flex-end;
  margin-top: 16px;
}
</style>

个人中心 profile 页面业务逻辑回填

一、template 模板改动说明

页面整体布局、头像区域、分割线、表单结构完全保留原始骨架,不增删任何标签 只回填数据绑定、插槽内容、表单属性、点击事件

1. 头像标签改动

回填后

<!-- 绑定头像地址 + 用户名首字母默认展示 -->
<el-avatar :size="96" :src="form.avatar">
  {{ avatarText }}
</el-avatar>
2. 头像输入框

新增双向数据绑定

<el-input v-model="form.avatar" placeholder="请输入头像图片链接(可选)" clearable style="width: 320px" />
3. 外层表单整体回填属性
<!-- 新增表单实例、数据绑定、校验规则 -->
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" class="profile-form">
4. 各个表单项回填
  • 用户名输入框:新增  :model-value  数据回显
  • 昵称、邮箱、手机号输入框:全部添加  v-model  双向绑定、表单校验prop
  • 保存按钮:新增点击保存事件  @click="handleSave" 

二、script 脚本新增逻辑

模块1:导入项目依赖
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user'

作用:引入vue响应式API、消息提示、全局用户信息仓库

模块2:获取用户仓库与表单实例
const userStore = useUserStore()
const formRef = ref(null)
模块3:回填用户信息表单数据

从全局仓库读取登录用户信息,赋值给表单

const form = reactive({
  username: userStore.userInfo.username || 'admin',
  nickname: userStore.userInfo.nickname || '',
  email: userStore.userInfo.email || '',
  phone: userStore.userInfo.phone || '',
  avatar: userStore.userInfo.avatar || ''
})
模块4:头像默认文字计算属性
const avatarText = computed(() => (form.username ? form.username.slice(0, 1).toUpperCase() : 'U'))

功能:没有头像链接时,自动展示用户名第一个大写字母

模块5:表单校验规则
const rules = {
  nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
  ],
  phone: [
    { required: true, message: '请输入手机号', trigger: 'blur' },
    { pattern: /^1\d{10}$/, message: '手机号格式不正确', trigger: 'blur' }
  ]
}

作用:校验昵称、邮箱、手机号格式与非空

模块6:个人信息保存核心方法
const handleSave = async () => {
  // 表单校验
  await formRef.value.validate()
  // 更新全局用户信息
  userStore.updateUserInfo({
    nickname: form.nickname,
    email: form.email,
    phone: form.phone,
    avatar: form.avatar
  })
  ElMessage.success('个人信息保存成功')
}

执行流程: 表单校验 → 提交数据 → 更新仓库用户信息 → 保存成功提示

三、回填完成 · 个人中心完整最终代码

<template>
  <div class="profile-page page-card">
    <div class="avatar-section">
      <el-avatar :size="96" :src="form.avatar">
        {{ avatarText }}
      </el-avatar>
      <div class="avatar-actions">
        <div class="avatar-title">用户头像</div>
        <el-input
          v-model="form.avatar"
          placeholder="请输入头像图片链接(可选)"
          clearable
          style="width: 320px"
        />
        <div class="avatar-tip">不填写时将显示用户名首字母。</div>
      </div>
    </div>

    <el-divider />

    <el-form ref="formRef" :model="form" :rules="rules" label-width="90px" class="profile-form">
      <el-form-item label="用户名">
        <el-input :model-value="form.username" readonly />
      </el-form-item>
      <el-form-item label="昵称" prop="nickname">
        <el-input v-model="form.nickname" placeholder="请输入昵称" />
      </el-form-item>
      <el-form-item label="邮箱" prop="email">
        <el-input v-model="form.email" placeholder="请输入邮箱" />
      </el-form-item>
      <el-form-item label="手机号" prop="phone">
        <el-input v-model="form.phone" placeholder="请输入手机号" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="handleSave">保存修改</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user'

const userStore = useUserStore()
const formRef = ref(null)

const form = reactive({
  username: userStore.userInfo.username || 'admin',
  nickname: userStore.userInfo.nickname || '',
  email: userStore.userInfo.email || '',
  phone: userStore.userInfo.phone || '',
  avatar: userStore.userInfo.avatar || ''
})

const avatarText = computed(() => (form.username ? form.username.slice(0, 1).toUpperCase() : 'U'))

const rules = {
  nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
  ],
  phone: [
    { required: true, message: '请输入手机号', trigger: 'blur' },
    { pattern: /^1\d{10}$/, message: '手机号格式不正确', trigger: 'blur' }
  ]
}

const handleSave = async () => {
  if (!formRef.value) return
  await formRef.value.validate()
  userStore.updateUserInfo({
    nickname: form.nickname,
    email: form.email,
    phone: form.phone,
    avatar: form.avatar
  })
  ElMessage.success('个人信息保存成功')
}
</script>

<style scoped lang="scss">
.profile-page {
  padding: 20px;
}

.avatar-section {
  display: flex;
  gap: 16px;
  align-items: center;
}

.avatar-actions {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.avatar-title {
  color: #2f5b96;
  font-size: 15px;
  font-weight: 600;
}

.avatar-tip {
  color: #87a2c7;
  font-size: 12px;
}

.profile-form {
  max-width: 560px;
}
</style>

Pinia User.js业务逻辑补全

分步补充 + 每一步说明新增作用

第1步:定义本地存储常量

新增:

// 本地存储key常量,统一管理
const TOKEN_KEY = 'admin_token'
const USER_INFO_KEY = 'admin_user_info'

作用: 把 token、用户信息存在 localStorage 的键名抽成常量,后期改名字只改一处就行。

第2步:重构 state 状态,扩充字段 + 读取本地缓存

原来state只有  username、isLogin  替换完善后:

state: () => ({
  // 登录令牌,从本地缓存读取
  token: localStorage.getItem(TOKEN_KEY) || '',
  // 完整用户信息,无缓存给默认空对象
  userInfo: JSON.parse(localStorage.getItem(USER_INFO_KEY) || 'null') || {
    username: '',
    nickname: '',
    email: '',
    phone: '',
    avatar: ''
  }
})

新增&改动说明:

1. 删掉简陋的  isLogin  字面变量

2. 新增  token  作为登录身份凭证

3. 新增  userInfo  存放全套个人资料(用户名、昵称、邮箱、手机号、头像)

4. 初始化自动从  localStorage  读取,刷新页面登录状态不丢失

第3步:新增 getters 计算属性

getters: {
  // 通过是否有token,统一判断是否登录
  isLogin: (state) => Boolean(state.token)
}

作用:

  • 统一封装登录判断逻辑
  • 后面路由守卫、layout页面直接用  userStore.isLogin ,不用重复写判断token

第4步:补全 actions 三个核心方法

4.1 新增 login 登录方法
login(loginForm) {
  // 模拟后端生成token
  const mockToken = `token_${Date.now()}`
  this.token = mockToken
  // 赋值用户信息
  this.userInfo = {
    username: loginForm.username,
    nickname: '系统管理员',
    email: 'admin@example.com',
    phone: '13800138000',
    avatar: ''
  }
  // 状态持久化到本地
  localStorage.setItem(TOKEN_KEY, mockToken)
  localStorage.setItem(USER_INFO_KEY, JSON.stringify(this.userInfo))
}

作用: 登录页调用 → 保存token、用户信息到Pinia + 本地缓存

4.2 新增 updateUserInfo 更新个人信息方法
updateUserInfo(payload) {
  // 合并原有信息和新修改的字段
  this.userInfo = {
    ...this.userInfo,
    ...payload
  }
  // 同步更新本地缓存
  localStorage.setItem(USER_INFO_KEY, JSON.stringify(this.userInfo))
}

作用: 个人中心页面保存修改时调用 → 局部更新用户资料,不覆盖原有字段

4.3 logout 退出登录方法
logout() {
  // 清空pinia状态
  this.token = ''
  this.userInfo = {
    username: '',
    nickname: '',
    email: '',
    phone: '',
    avatar: ''
  }
  // 清空本地存储
  localStorage.removeItem(TOKEN_KEY)
  localStorage.removeItem(USER_INFO_KEY)
}

作用: Layout头部退出按钮调用 → 清空登录状态、清空本地缓存

完整 Pinia 最终代码

import { defineStore } from 'pinia'

// 本地存储key常量
const TOKEN_KEY = 'admin_token'
const USER_INFO_KEY = 'admin_user_info'

export const useUserStore = defineStore('user', {
  state: () => ({
    token: localStorage.getItem(TOKEN_KEY) || '',
    userInfo: JSON.parse(localStorage.getItem(USER_INFO_KEY) || 'null') || {
      username: '',
      nickname: '',
      email: '',
      phone: '',
      avatar: ''
    }
  }),

  getters: {
    isLogin: (state) => Boolean(state.token)
  },

  actions: {
    // 登录:保存token和用户信息
    login(loginForm) {
      const mockToken = `token_${Date.now()}`
      this.token = mockToken
      this.userInfo = {
        username: loginForm.username,
        nickname: '系统管理员',
        email: 'admin@example.com',
        phone: '13800138000',
        avatar: ''
      }
      localStorage.setItem(TOKEN_KEY, mockToken)
      localStorage.setItem(USER_INFO_KEY, JSON.stringify(this.userInfo))
    },

    // 更新个人资料
    updateUserInfo(payload) {
      this.userInfo = { ...this.userInfo, ...payload }
      localStorage.setItem(USER_INFO_KEY, JSON.stringify(this.userInfo))
    },

    // 退出登录
    logout() {
      this.token = ''
      this.userInfo = {
        username: '',
        nickname: '',
        email: '',
        phone: '',
        avatar: ''
      }
      localStorage.removeItem(TOKEN_KEY)
      localStorage.removeItem(USER_INFO_KEY)
    }
  }
})

router路由配置业务逻辑补全

分步增补改造

步骤1:引入 Pinia 用户仓库

在顶部新增导入:

import { useUserStore } from '@/store/modules/user'

作用 路由守卫需要读取  isLogin  登录状态,做页面访问权限拦截。

步骤2:路由统一改成「懒加载」+ 补充 name、meta 元信息

1. 所有页面都改成路由懒加载  () => import() ,减小首屏体积

2. 给每个路由加  name  命名,便于编程式跳转

3. 新增  meta: { title: '页面名称' } ,用来动态设置浏览器标签标题

改造后单个路由示例:

{
  path: '/login',
  name: 'Login',
  component: () => import('@/views/login/index.vue'),
  meta: { title: '登录' }
}

步骤3:新增 404 兜底路由

加到 routes 最后一项:

{
  path: '/:pathMatch(.*)*',
  redirect: '/home'
}

作用 访问不存在的地址,自动跳转到首页,避免空白页。

步骤4:新增全局路由守卫  beforeEach

router.beforeEach((to) => {
  const userStore = useUserStore()
  const hasToken = userStore.isLogin

  // 未登录:除登录页外全部拦截,跳登录
  if (!hasToken && to.path !== '/login') {
    return '/login'
  }

  // 已登录:禁止再进入登录页,直接跳首页
  if (hasToken && to.path === '/login') {
    return '/home'
  }

  // 动态设置浏览器网页标题
  if (to.meta?.title) {
    document.title = `${to.meta.title} - 图书后台管理系统`
  } else {
    document.title = '图书后台管理系统'
  }

  return true
})

三大核心功能:

1. 登录权限拦截:没登录只能看登录页

2. 重复登录拦截:已登录不能回登录页

3. 动态网页标题:根据路由 meta 自动改标签名

完整路由代码

import { createRouter, createWebHistory } from 'vue-router'
// 引入pinia用户仓库,用于路由守卫权限控制
import { useUserStore } from '@/store/modules/user'

const routes = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/login/index.vue'),
    meta: { title: '登录' }
  },
  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    redirect: '/home',
    children: [
      {
        path: 'home',
        name: 'Home',
        component: () => import('@/views/home/index.vue'),
        meta: { title: '首页' }
      },
      {
        path: 'books',
        name: 'Books',
        component: () => import('@/views/books/index.vue'),
        meta: { title: '图书管理' }
      },
      {
        path: 'profile',
        name: 'Profile',
        component: () => import('@/views/profile/index.vue'),
        meta: { title: '个人中心' }
      }
    ]
  },
  // 404兜底路由
  {
    path: '/:pathMatch(.*)*',
    redirect: '/home'
  }
]

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

// 全局路由守卫
router.beforeEach((to) => {
  const userStore = useUserStore()
  const hasToken = userStore.isLogin

  // 未登录拦截
  if (!hasToken && to.path !== '/login') {
    return '/login'
  }

  // 已登录禁止进入登录页
  if (hasToken && to.path === '/login') {
    return '/home'
  }

  // 设置网页标题
  if (to.meta?.title) {
    document.title = `${to.meta.title} - 图书后台管理系统`
  } else {
    document.title = '图书后台管理系统'
  }

  return true
})

export default router

收尾调试与项目现存可优化点总结

调试部分

业务逻辑代码虽然全部写完了,但编码完成不等于项目可用,还需要做一轮基础调试自检:

对于这个小项目主要简单验证这几块就行:

  • 路由跳转、登录拦截是否正常生效
  • 刷新页面,登录状态、用户信息是否持久化保留
  • 图书新增、编辑、删除、查询分页流程是否通顺无报错
  • 个人中心修改信息后,全局状态是否同步更新

简单跑一遍核心流程,确保没有明显 Bug、逻辑能正常闭环就行,不用做专业级测试用例。

项目现存不足 & 可优化点

目前项目虽然功能完整,但偏业务实现版,工程化复用和封装还比较初级,主要不足有这些:

1. 组件没有抽离封装 搜索栏、表格、新增编辑弹窗都写在页面内部,没有抽成公共组件,复用性差。

2. 业务逻辑没做抽离 所有逻辑都写在页面  script setup  里,没有用 Vue3 自定义 Hook 拆分,后期不好维护。

3. 模拟数据、工具方法散落页面 图书模拟数据、时间格式化方法直接写在页面,没有统一抽离到 mock、utils 目录管理。

4. 没有封装统一请求层 目前都是前端本地模拟数据,没有封装 axios 统一请求,后续对接后端还要大改。

5. Pinia 和路由偏基础用法 只用了基础登录状态管理,没有按业务拆分仓库;路由只有基础登录拦截,没做动态菜单、细粒度权限控制。

6. 很多写法偏硬编码 状态标识、文字、配置都直接写死在页面里,没有抽离全局常量管理。

图书后台管理系统 整体总结

至此,这个简易Vue3 图书后台管理系统 主体开发全部完成。

项目遵循先页面骨架、后业务逻辑、最后底层架构的开发思路,依次完成登录、布局、首页、图书管理、个人中心页面搭建;实现图书查询、筛选、分页、增删改查全业务,以及个人信息编辑、表单校验等功能;再配合 Pinia 状态管理 和 Vue Router 路由守卫,实现登录持久化、路由权限拦截,整套系统业务流程完全闭环。

经过核心流程简易调试,主干功能运行稳定。目前虽已满足图书管理基础使用,但仍存在组件未封装、逻辑未抽离、工程化复用性不高等问题,后续可从公共组件抽取、业务Hook拆分、接口封装、权限细化等方向继续优化迭代。

通过这个图书后台管理系统的完整梳理,不仅熟练了 Vue3 组合式 API、Pinia 状态管理、Vue Router 路由守卫在实战中的落地用法,也锻炼前端项目拆分开发、分层构建、先功能后优化的思维模式,不管是作为练手实战、项目案例,还是后续二次扩展开发,都具备一定的参考价值。

前端监控体系与实践(二):全局监控

继上一篇前端监控体系与实践:从错误上报到内存与 GC 观测,当需要全局监控时该如何实施呢?

监控采集集中为 initClientMonitoring(),在应用入口调用一次。常见做法是在 main.js 中、创建根实例之前调用;若需复用,可封装为 Vue 插件,在 install 中调用同一函数。

init 中通常注册:errorunhandledrejection;对 history.pushState / replaceState 做包装并监听 popstate 以记录路由变化;可按需通过 PerformanceObserver 采集 LCP。若另有 GC 相关探针,可通过自定义事件 frontend-monitor:gc-suspect 由业务侧 dispatchEvent,非必需。


采集模块示例

环境变量命名需与构建工具一致(Vue CLI 常用 VUE_APP_*)。以下为 clientMonitor.js 示例:

/**
 * 客户端全局监控:错误、未处理 Promise、路由变化、基础 Web Vitals(LCP)。
 * 上报走 window.__FRONTEND_MONITOR_REPORT__(payload)。
 *
 * payload.type 约定:error | unhandledrejection | navigation | web-vital | gc-suspect
 */

function isMonitorEnabled() {
  if (typeof window === "undefined") return false;
  if (process.env.VUE_APP_FRONTEND_MONITOR === "1") return true;
  return process.env.NODE_ENV === "development";
}

function isGcReportEnabled() {
  if (typeof window === "undefined") return false;
  if (process.env.VUE_APP_FRONTEND_MONITOR_GC === "1") return true;
  return process.env.NODE_ENV === "development";
}

function report(payload) {
  const fn = window.__FRONTEND_MONITOR_REPORT__;
  if (typeof fn === "function") {
    try {
      fn(payload);
    } catch (e) {
      /* 上报回调异常不应影响主流程 */
    }
  }
  if (process.env.NODE_ENV === "development") {
    console.debug("[frontend-monitor]", payload);
  }
}

let installed = false;

export function initClientMonitoring() {
  if (typeof window === "undefined" || installed) return;
  if (!isMonitorEnabled()) return;
  installed = true;

  window.addEventListener("error", (ev) => {
    const err = ev.error;
    report({
      type: "error",
      message: ev.message || String(err != null ? err : "unknown"),
      source: ev.filename,
      lineno: ev.lineno,
      colno: ev.colno,
      stack: err instanceof Error ? err.stack : undefined,
    });
  });

  window.addEventListener("unhandledrejection", (ev) => {
    const r = ev.reason;
    const reason =
      r instanceof Error
        ? r.message + "\n" + (r.stack || "")
        : String(r);
    report({ type: "unhandledrejection", reason });
  });

  const path = () =>
    window.location.pathname + window.location.search + window.location.hash;

  report({ type: "navigation", kind: "initial", path: path() });

  window.addEventListener("popstate", () => {
    report({ type: "navigation", kind: "popstate", path: path() });
  });

  const origPush = history.pushState.bind(history);
  history.pushState = function () {
    origPush.apply(history, arguments);
    report({ type: "navigation", kind: "pushState", path: path() });
  };
  const origReplace = history.replaceState.bind(history);
  history.replaceState = function () {
    origReplace.apply(history, arguments);
    report({ type: "navigation", kind: "replaceState", path: path() });
  };

  if (isGcReportEnabled()) {
    window.addEventListener("frontend-monitor:gc-suspect", (ev) => {
      const d = ev.detail;
      if (d) report({ type: "gc-suspect", id: d.id, aliveMs: d.aliveMs });
    });
  }

  try {
    const po = new PerformanceObserver((list) => {
      for (const e of list.getEntries()) {
        if (e.entryType === "largest-contentful-paint") {
          report({
            type: "web-vital",
            name: "LCP",
            value: Math.round(e.startTime),
          });
        }
      }
    });
    po.observe({ type: "largest-contentful-paint", buffered: true });
  } catch (e) {
    /* 浏览器不支持 LCP observer */
  }
}

installed 用于防止重复初始化。上述监听绑定在 windowhistory 上,与具体页面组件无关,不宜分散到各页面的 mounted 中重复注册。


main.js

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import { initClientMonitoring } from "@/lib/monitoring/clientMonitor";

initClientMonitoring();

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

若初始化依赖远程配置(例如先请求 /config 再决定是否开启监控),应注意:errorunhandledrejection 注册过晚时,可能在脚本加载初期遗漏部分异常。


Vue 插件封装(可选)

// plugins/clientMonitoring.js
import { initClientMonitoring } from "@/lib/monitoring/clientMonitor";

export default {
  install() {
    initClientMonitoring();
  },
};

// main.js
import ClientMonitoring from "./plugins/clientMonitoring";
Vue.use(ClientMonitoring);

同一插件多次 Vue.use 只会执行一次 install,与模块内 installed 标志可并存,择一即可。


router.afterEach 是否必要

在已包装 history 的前提下,vue-router 使用 History 模式时,路由切换通常会触发 pushState / replaceState仅按 URL 做埋点或 RUM 时,一般不必再写 afterEach,否则易与历史 API 包装产生重复上报,需在服务端或协议层约定去重。

当需要 路由名称、meta 等无法从 URL 直接还原的信息(例如实验分组、业务归属)时,可在 router.afterEach 中调用 __FRONTEND_MONITOR_REPORT__ 单独上报;字段需与网关或数据模型一致。


Vue.config.errorHandler

组件渲染与生命周期中的错误未必冒泡至 windowerror 事件,建议在 main.js 中配置全局 errorHandler

Vue.config.errorHandler = (err, vm, info) => {
  const fn = window.__FRONTEND_MONITOR_REPORT__;
  if (typeof fn === "function") {
    try {
      fn({
        type: "error",
        message: err && err.message ? err.message : String(err),
        stack: err instanceof Error ? err.stack : undefined,
        source: info,
      });
    } catch (_) {}
  }
  if (process.env.NODE_ENV === "development") {
    console.error("[vue-error]", err, info);
  }
};

若在祖先组件中使用 errorCaptured 拦截子树错误,应与 errorHandler 的上报策略一并设计,避免同一异常多次上报。


上报接入

window.__FRONTEND_MONITOR_REPORT__ = function (payload) {
  // sendBeacon / fetch
};

采集逻辑集中在 init 中实现;上报通过全局回调转发,变更采集端点或采样策略时,优先修改该回调或其封装层。

上一篇: 前端监控体系与实践:从错误上报到内存与 GC 观测

Vue线上代码调试全攻略(安全无侵入,新手也能上手)

Vue线上代码调试的核心痛点的是:线上代码经过压缩、混淆、编译处理,无法直接对应本地源码,且不能随意修改线上代码、泄露敏感信息。本文聚焦Vue项目(Vue2/Vue3通用),分享4种高频、安全的线上调试方法,覆盖“报错定位、代码调试、接口排查”,无需改动线上部署包,兼顾调试效率和生产环境安全。

核心原则:线上调试优先“无侵入式”,避免影响用户使用;调试完成后,需及时清理调试痕迹,杜绝敏感信息泄露和代码冗余。

一、基础调试:Chrome开发者工具(最常用,零成本)

Chrome DevTools是Vue线上调试的核心工具,无需额外配置,重点利用「Sources」「Network」「Console」面板,结合Source Map实现“压缩代码→原始源码”的映射,精准定位问题。

1. 开启Source Map(关键前提)

线上代码通常会经过压缩、混淆(如变量名缩短、代码合并),直接调试压缩代码无法定位到本地源码,而Source Map(源码映射)可解决这一问题——它就像“代码翻译字典”,能将压缩后的代码反向映射回未处理的原始源码(.vue、.js文件),是线上报错定位的关键工具。

配置方法(Vue2/Vue3通用):

  • Vue CLI项目(Webpack构建):在vue.config.js中配置devtool,生成Source Map文件(线上建议用hidden-source-map,既不暴露源码,又能支持调试); // vue.config.js(线上配置) `` module.exports = { `` configureWebpack: { `` devtool: 'hidden-source-map' // 推荐线上使用,不暴露源码但支持调试 `` // 避免使用source-map(会直接暴露源码,有安全风险) `` } ``}
  • Vite构建项目:在vite.config.js中开启sourcemap配置; // vite.config.js(线上配置) `` import { defineConfig } from 'vite' `` import vue from '@vitejs/plugin-vue' ```` export default defineConfig({ `` plugins: [vue()], `` build: { `` sourcemap: true // 开启Source Map生成 `` } ``})

配置后,构建时会生成.map后缀的Source Map文件,线上代码末尾会添加注释关联该文件,浏览器加载后可自动完成映射。

2. 实操步骤(定位报错+断点调试)

  1. 打开线上Vue项目,按F12打开Chrome DevTools,切换到「Sources」面板;
  2. 点击面板左侧「Page」→ 找到当前项目域名 → 展开后可看到压缩后的js文件(如app.[hash].js);
  3. 若已配置Source Map,点击文件左下角的「{}」(格式化代码),DevTools会自动将压缩代码映射为可读性强的代码,同时关联原始源码文件(可在左侧「Sources」面板找到src目录下的.vue/.js文件);
  4. 断点调试:在映射后的源码(如.vue文件的script部分)点击行号,添加断点,触发对应操作(如点击按钮、跳转页面),代码会在断点处暂停,可查看变量值、调用栈,逐步排查问题;
  5. 报错定位:若线上出现报错,Console面板会显示报错信息,点击报错信息后的文件路径(如src/views/Home.vue:20),可直接跳转到报错对应的原始源码行,快速定位问题根源。

3. 补充:Console面板调试(临时查看数据)

线上可通过Console面板临时查看Vue实例、组件数据,无需修改代码:

  • Vue2:在Console输入vm = document.querySelector('vue-app').__vue__,获取根实例,可查看vm.datavm.data、vm.props、vm.$refs等,甚至临时调用方法(如vm.handleClick());
  • Vue3:在Console输入vm = document.querySelector('vue-app').__vue_app__._instance,获取根组件实例,通过vm.proxy访问响应式数据(如vm.proxy.message);
  • 注意:Console调试仅用于临时查看,避免在Console中修改敏感数据(如用户token、隐私信息),调试完成后清空Console记录。

二、Vue专属调试:Vue Devtools(组件/响应式数据调试)

Vue Devtools是专为Vue设计的浏览器插件,不仅适用于开发环境,也可用于线上调试,能直观查看组件树、响应式数据、路由信息,快速排查组件相关问题,是Vue开发者的必备调试工具。

1. 线上启用方法(解决“线上无法激活”问题)

默认情况下,Vue Devtools在生产环境(线上)会被禁用,需通过以下方法启用:

  1. 安装Vue Devtools插件(Chrome/Firefox均可,推荐Chrome);

  2. 打开线上Vue项目,按F12打开DevTools,切换到「Vue」面板(若没有,需重启DevTools);

  3. 若面板提示“Vue.js not detected”,按以下步骤操作:

    1. Vue2:在Console输入Vue.config.productionTip = true,刷新页面,即可激活Vue Devtools;
    2. Vue3:在Console输入window.__VUE_DEVTOOLS_GLOBAL_HOOK__.enable=true,刷新页面,激活插件。

2. 核心调试功能(针对性解决Vue问题)

  • 组件树查看:在「Components」面板,可查看整个项目的组件嵌套结构,选中任意组件,右侧可查看该组件的props、data、computed、refs等,还能实时编辑数据(如修改data中的值),查看页面变化,快速定位组件数据异常问题;
  • 响应式数据调试:在「State」面板(Vue3)/「Vuex」面板(Vue2),可查看Pinia/Vuex的全局状态,实时监控状态变化,排查状态更新异常、数据同步问题;
  • 路由调试:在「Router」面板,可查看当前路由、路由参数、路由历史,模拟路由跳转(无需刷新页面),排查路由跳转异常、参数传递问题;
  • 生命周期调试:可查看组件的生命周期钩子执行情况,判断钩子函数是否正常触发,排查生命周期相关的逻辑问题。

三、日志调试:规范日志收集(线上故障可追溯)

线上调试的核心痛点之一是“无法复现场景”,尤其是偶发故障,此时通过日志收集,可记录用户操作链路、错误信息,实现故障追溯,替代杂乱的console.log,同时避免敏感信息泄露。

1. 日志框架选型与配置(Vue项目推荐)

不推荐直接使用console.log(易泄露敏感信息、日志杂乱),建议使用专业日志框架,实现日志分级、环境区分、远程上报,常用框架如下:

  • 轻量首选:loglevel(无依赖、体积极小,支持多环境日志控制,适配Vue2/Vue3,可快速替代console.log);
  • Vue专属:vue-logger-plugin(专为Vue设计,零侵入、开箱即用,支持日志分级、格式化输出,适配组合式API);
  • 大型项目:pino(高性能,支持结构化JSON日志,便于日志分析工具解析,适配高并发场景)。

配置示例(以loglevel为例,Vue3组合式API):

// 1. 安装
// npm install loglevel --save

// 2. 封装日志工具(src/utils/logger.js)
import log from 'loglevel';

// 配置:开发环境显示所有日志,线上环境仅显示错误日志
if (import.meta.env.PROD) {
  log.setLevel('error'); // 线上仅输出error级别日志
} else {
  log.setLevel('debug'); // 开发环境输出所有级别日志
}

// 脱敏处理:隐藏敏感信息(如token、手机号)
export const logger = {
  debug: (msg, data = {}) => log.debug(msg, filterSensitiveData(data)),
  info: (msg, data = {}) => log.info(msg, filterSensitiveData(data)),
  warn: (msg, data = {}) => log.warn(msg, filterSensitiveData(data)),
  error: (msg, data = {}) => log.error(msg, filterSensitiveData(data))
};

// 敏感信息脱敏函数
function filterSensitiveData(data) {
  if (typeof data !== 'object' || data === null) return data;
  const cloneData = JSON.parse(JSON.stringify(data));
  if (cloneData.token) cloneData.token = '***';
  if (cloneData.phone) cloneData.phone = cloneData.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
  return cloneData;
}

2. 日志使用与远程上报

  • 代码中使用:在关键逻辑(如接口请求、按钮点击、异常捕获)处添加日志,记录操作信息和数据; <script setup> `` import { logger } from '@/utils/logger'; `` import axios from 'axios'; ```` const getList = async () => { `` try { `` logger.info('请求列表接口', { url: '/api/list', params: { page: 1 } }); `` const res = await axios.get('/api/list', { params: { page: 1 } }); `` logger.debug('列表接口响应', res.data); `` } catch (err) { `` logger.error('列表接口请求失败', { err: err.message }); `` } `` }; ``</script>

  • 远程上报:将线上错误日志上报至服务器或第三方监控平台(如Sentry),步骤如下:

    • 安装Sentry SDK:npm install @sentry/vue @sentry/vite-plugin --save-dev(Vite项目);
    • 配置vite.config.js,自动生成并上传Source Map; import { defineConfig } from 'vite'; `` import vue from '@vitejs/plugin-vue'; `` import { sentryVitePlugin } from '@sentry/vite-plugin'; ```` export default defineConfig({ `` build: { sourcemap: true }, // 必须开启Source Map `` plugins: [ `` vue(), `` sentryVitePlugin({ `` authToken: '你的Sentry令牌', `` org: '你的Sentry组织', `` project: '你的项目名' `` }) `` ] ``});
    • 线上出现错误时,Sentry会自动收集错误日志、调用栈、设备环境,开发者可通过Sentry后台查看详细信息,快速复现场景、定位问题。

四、接口调试:排查接口异常(线上常见问题)

Vue线上问题多与接口相关(如接口报错、数据返回异常),可通过Chrome DevTools的「Network」面板和接口调试工具,快速排查接口问题,无需修改线上代码。

1. Network面板调试(查看接口详情)

  1. 打开线上项目,按F12进入DevTools,切换到「Network」面板,勾选「XHR/Fetch」(只显示接口请求);

  2. 触发接口请求(如刷新页面、点击按钮),面板会显示所有接口的请求信息,包括请求URL、方法、状态码、请求头、响应数据;

  3. 排查重点:

    1. 状态码:4xx(客户端错误,如参数错误、权限不足)、5xx(服务端错误),点击接口查看「Response」面板,获取错误信息;
    2. 请求头:检查是否携带Token、Cookie等关键信息,是否与后端要求一致;
    3. 响应数据:查看返回的数据是否符合预期,是否存在数据缺失、格式错误等问题;
    4. 请求参数:点击「Payload」面板,查看请求参数是否正确,排查参数传递异常问题。

2. 接口重放与模拟(复现场景)

若接口返回异常,可通过「Network」面板重放接口,修改参数测试,无需修改线上代码:

  1. 在Network面板选中异常接口,右键选择「Replay XHR」,可重放该接口,查看是否为偶发问题;
  2. 若需修改参数测试,右键选择「Edit and Resend」,修改请求参数、请求头,点击「Send」,查看修改后的响应结果,快速定位参数问题;
  3. 补充:可使用Postman、Apifox等工具,复制线上接口的请求信息,模拟接口请求,对比线上响应与本地测试环境的差异,排查环境相关问题。

五、进阶调试:临时修改线上代码(紧急排查)

若需临时修改线上代码(如验证某个逻辑、绕过某个bug),可通过Chrome DevTools的「Overrides」功能,临时替换线上文件,不影响其他用户,调试完成后需立即撤销。

  1. 打开Chrome DevTools,切换到「Sources」面板,点击左侧「Overrides」→ 点击「Select folder for overrides」,选择本地一个空文件夹(用于存储临时修改的文件);
  2. 在「Page」面板找到线上需要修改的文件(如src/views/Home.vue,需开启Source Map),右键选择「Save for overrides」,将文件保存到本地文件夹;
  3. 双击文件,在DevTools中修改代码(如添加日志、修改逻辑),保存后,页面会自动刷新,执行修改后的代码;
  4. 调试完成后,删除本地文件夹中的临时文件,在「Overrides」面板取消勾选「Enable local overrides」,恢复线上原始代码。

六、调试避坑与安全注意事项

  • 禁止线上暴露源码:Source Map配置需谨慎,避免使用source-map(会直接暴露完整源码),优先使用hidden-source-map,仅支持调试但不暴露源码;
  • 杜绝敏感信息泄露:调试时不打印用户token、手机号、隐私数据,日志需做脱敏处理,调试完成后清空Console记录;
  • 不影响线上用户:临时修改线上代码(Overrides功能)仅对当前浏览器生效,不影响其他用户,调试完成后必须撤销修改;
  • 避免过度调试:线上调试以“定位问题”为主,不建议在Console中执行复杂逻辑,避免触发线上异常;
  • 调试后清理痕迹:日志框架在上线前需配置正确的日志级别(线上仅输出error),避免冗余日志占用资源;临时添加的调试代码,上线前必须删除。

七、总结(实操优先级)

Vue线上调试的实操优先级:「Chrome DevTools(Source Map+断点)」→「Vue Devtools(组件/响应式调试)」→「日志收集(远程上报)」→「接口调试(Network)」→「临时修改代码(Overrides)」。

日常线上排查时,优先通过Source Map定位报错,用Vue Devtools排查组件和数据问题,用日志和Network面板追溯故障场景;紧急情况下,可通过Overrides临时修改代码验证逻辑,核心是“安全、无侵入、不影响用户”,快速定位并解决问题。

Vue3 超全复盘!30+前端高频核心知识点(开发+面试全覆盖)

Vue3 作为目前前端项目的主流技术栈,无论是日常业务开发、工程化项目搭建,还是前端面试,都是必考且核心的技术重点

很多开发者长期使用 Vue3 开发,但知识点零散、体系混乱,面试时无法系统作答,开发时也容易写出不规范的代码。

本文系统化复盘 30+ Vue3 高频知识点,涵盖组合式API、响应式原理、生命周期、组件通信、性能优化、新特性、避坑指南七大模块,全部为实战高频考点,结构清晰、干货密集,适合收藏复盘、查漏补缺、面试背诵。


一、Vue3 整体核心优势(面试开篇必答)

相比 Vue2,Vue3 在架构、性能、语法、工程化上全面升级,核心优势集中在 5 点:

  1. 性能大幅提升:重写虚拟 DOM、优化 diff 算法、支持静态提升、预字符化,初始渲染和更新渲染速度更快
  2. 体积更小:全面模块化、按需引入、Tree-Shaking 友好,打包体积大幅压缩
  3. 组合式 API:替代 Options 选项式 API,解决大型项目代码碎片化、逻辑分散问题,支持逻辑抽离与复用
  4. 更强的 TS 支持:源码基于 TS 重写,类型推断完善,大型项目类型约束更严谨
  5. 全新响应式原理:基于 Proxy 替代 Object.defineProperty,解决 Vue2 响应式底层缺陷

二、组合式 API 核心知识点(开发最常用)

组合式 API 是 Vue3 最大的更新,也是日常开发使用率最高的语法,下面汇总高频核心用法与知识点。

1. setup 函数

  • Vue3 组合式 API 的入口函数,组件创建前执行,比生命周期更早
  • 无法使用 this,this 指向 undefined
  • 内部定义的变量、函数,需要 return 后才可在模板中使用(script setup 语法糖无需手动 return)
  • 支持同步写法,不支持 async/await 顶层异步(会阻塞组件渲染)
<script setup>
// 【规范写法】script setup 语法糖
// 无需手动return、无需注册组件,代码极简
let msg = 'Vue3 setup 入门'

function showMsg() {
  console.log(msg)
}
</script>

<template>
  <div>{{ msg }}</div>
</template>

避坑要点:禁止使用顶层 async setup,会导致组件渲染阻塞

<!-- 错误写法:顶层async,阻塞组件挂载 -->
<script setup async>
  const res = await fetch('/api/list')
</script>

2. ref 基础响应式

  • 用于定义基本数据类型响应式数据:String、Number、Boolean、Null、Undefined
  • 底层通过类实例实现响应式,数据默认包裹在 .value
  • 模板中可省略 .value,JS 逻辑中必须手动书写 .value
  • 也可兼容定义对象、数组,但不推荐,性能不如 reactive
<script setup>
import { ref } from 'vue'

// 【正确写法】基础类型使用ref定义响应式
const count = ref(0)
const name = ref('前端复盘')

// JS逻辑中必须通过.value修改值
const add = () => {
  count.value++
}
</script>

<template>
  <div>
    <p>数值:{{ count }}</p>
    <p>名称:{{ name }}</p>
    <button @click="add">自增</button>
  </div>
</template>

错误写法踩坑:基础类型直接定义,无响应式;ref对象直接赋值覆盖响应式

<script setup>
// 错误1:普通变量,非响应式,视图不更新
let num = 10

// 错误2:直接替换ref整个对象,丢失响应
const refNum = ref(0)
refNum = 100 
</script>

3. reactive 响应式对象

  • 专门用于定义引用类型响应式数据:对象、数组、嵌套复杂数据
  • 基于 Proxy 实现,无需 .value,直接访问属性
  • 支持深度响应式,默认递归监听所有嵌套属性
  • 存在有限性:解构会丢失响应式、直接替换对象会丢失响应式
<script setup>
import { reactive } from 'vue'

// 【正确写法】引用类型使用reactive
const userInfo = reactive({
  name: 'Vue3开发者',
  age: 24,
  address: {
    city: '北京'
  }
})

// 直接修改属性,无需.value,深度响应式生效
const changeCity = () => {
  userInfo.address.city = '上海'
}
</script>

<template>
  <div>
    <p>城市:{{ userInfo.address.city }}</p>
    <button @click="changeCity">切换城市</button>
  </div>
</template>

错误写法踩坑:直接替换整个reactive对象、解构赋值,丢失响应式

<script setup>
import { reactive } from 'vue'
const state = reactive({ a: 1, b: 2 })

// 错误1:直接替换整个对象,响应式彻底丢失
// state = reactive({ a: 100 })

// 错误2:直接解构,变量脱离响应式追踪
// const { a } = state
</script>

4. toRefs 解构保留响应式

  • 解决 reactive 对象解构丢失响应式问题
  • 将 reactive 对象的每一个属性转为独立 ref 对象
  • 解构后依然保留双向响应式,是项目高频实用技巧
<script setup>
import { reactive, toRefs } from 'vue'

const state = reactive({
  title: 'Vue3复盘',
  num: 10
})

// 【错误写法】直接解构,丢失响应式,修改不更新视图
// const { title, num } = state

// 【正确写法】toRefs解构,保留完整响应式
const { title, num } = toRefs(state)

const changeTitle = () => {
  title.value = 'Vue3知识点汇总'
}
</script>

<template>
  <div>{{ title }} - {{ num }}</div>
</template>

5. toRef 精准创建属性响应式

  • 单独针对对象某个属性创建响应式引用
  • 适用于只需要监听单个属性、无需解构全部数据的场景
<script setup>
import { reactive, toRef } from 'vue'

const info = reactive({
  a: 1,
  b: 2,
  c: 3
})

// 【正确写法】单独绑定对象属性,保留响应式引用
const a = toRef(info, 'a')

const updateA = () => {
  a.value += 1
}
</script>

<template>
  <div>{{ a }}</div>
</template>

错误写法踩坑:直接赋值属性,属于普通变量,无响应联动

<script setup>
// 错误:只是普通数值拷贝,和原对象无联动
const a = info.a
a++ // 视图不更新,原对象值不变
</script>

6. computed 计算属性

  • 具备缓存机制,依赖不变则不重复计算,优于方法调用
  • 分为只读计算属性、可写计算属性
  • 自动收集依赖,依赖更新自动触发更新
<script setup>
import { ref, computed } from 'vue'

const price = ref(99)
const count = ref(2)

// 【正确1】只读计算属性(业务最常用)
const totalPrice = computed(() => {
  return price.value * count.value
})

// 【正确2】可写计算属性
const doubleCount = computed({
  get() {
    return count.value * 2
  },
  set(val) {
    count.value = val / 2
  }
})
</script>

<template>
  <div>总价:{{ totalPrice }}</div>
</template>

错误写法踩坑:用普通方法替代计算属性,无缓存,频繁重复计算,性能差

<script setup>
// 错误:每次渲染都会执行,无缓存,性能浪费
const getTotal = () => {
  return price.value * count.value
}
</script>

7. watch / watchEffect 监听机制

  • watch:精准监听指定数据,支持新旧值、深度监听、立即执行
  • watchEffect:自动收集依赖,无需手动传入监听源,立即执行、自动响应
  • watch 适合精准监听单一数据,watchEffect 适合多依赖自动监听场景
<script setup>
import { ref, reactive, watch, watchEffect } from 'vue'

const num = ref(0)
const user = reactive({ name: '张三', age: 20 })

// 【正确1】精准监听基础类型
watch(num, (newVal) => {
  console.log('数值更新:', newVal)
})

// 【正确2】监听复杂对象,开启立即执行+深度监听
watch(user, () => {
  console.log('用户信息更新')
}, { immediate: true, deep: true })

// 【正确3】watchEffect自动收集多依赖
watchEffect(() => {
  console.log('自动监听数据:', num.value, user.name)
})
</script>

错误写法踩坑:监听reactive对象不开启deep,嵌套属性更新不触发监听

<script setup>
// 错误:未开启deep,嵌套属性更新无法监听
watch(user, () => {
  console.log('更新')
})
user.age = 25 // 不触发监听回调
</script>

三、Vue3 生命周期知识点

Vue3 生命周期兼容 Vue2 写法,同时提供组合式 API 钩子,核心常用钩子 8 个,面试高频考察执行顺序。

  1. onBeforeCreate / onCreated:组件创建阶段,setup 替代大部分逻辑
  2. onBeforeMount / onMounted:DOM 挂载前后,异步请求、DOM 操作放在 onMounted
  3. onBeforeUpdate / onUpdated:数据更新、DOM 重渲染前后
  4. onBeforeUnmount / onUnmounted:组件销毁前后,用于清除定时器、监听事件、解绑全局监听
<script setup>
import { onMounted, onUpdated, onUnmounted } from 'vue'
let timer = null

// 【正确】DOM挂载后请求接口、初始化数据、开启定时器
onMounted(() => {
  console.log('组件挂载完成')
  timer = setInterval(() => {}, 1000)
})

// 数据更新DOM完成后执行
onUpdated(() => {
  console.log('组件更新完成')
})

// 【正确】组件销毁,清除副作用,防止内存泄漏
onUnmounted(() => {
  clearInterval(timer)
  timer = null
  console.log('组件销毁,清除定时器')
})
</script>

错误写法踩坑:所有逻辑堆在setup顶层、不清除副作用

<script setup>
// 错误1:顶层直接请求,执行时机不可控
// 错误2:不清除定时器,页面销毁内存泄漏
setInterval(() => {}, 1000)
</script>

核心考点:Vue3 取消了 beforeCreate、created,统一在 setup 中编写初始化逻辑;销毁钩子更名(destroyed → unmounted)。


四、Vue3 组件通信全方案(高频业务+面试)

Vue3 废弃了 Vue2 的 childrenchildren、listeners、事件总线等部分 API,提供更规范、更安全的通信方式,全覆盖 8 种通信方案。

1. 父子通信:props / defineProps

父传子核心方案,支持类型校验、默认值、必填校验,Vue3 推荐使用 defineProps 语法糖。

// 子组件 【正确写法】规范校验+默认值
<script setup>
const props = defineProps({
  title: {
    type: String,
    default: '默认标题'
  },
  list: {
    type: Array,
    default: () => [] // 引用类型必须函数返回
  }
})
</script>

// 父组件使用
<Child title="Vue3复盘" :list="[1,2,3]" />

错误写法踩坑:引用类型默认值直接写死,所有组件实例共享数据

<script setup>
// 错误:数组默认值字面量,多组件数据污染
const props = defineProps({
  list: {
    type: Array,
    default: []
  }
})
</script>

2. 子父通信:emit / defineEmits

子组件通过 defineEmits 自定义事件,向上传递数据,替代 Vue2 this.$emit。

// 子组件【正确写法】
<script setup>
// 声明自定义事件
const emit = defineEmits(['sendData'])

const send = () => {
  emit('sendData', '子组件传递的数据')
}
</script>

// 父组件接收
<Child @sendData="handleData" />
<script setup>
const handleData = (res) => {
  console.log('接收子组件数据:', res)
}
</script>

错误写法踩坑:script setup 中直接使用 this.$emit(Vue3语法失效)

<script setup>
// 错误:setup无this,直接报错
this.$emit('sendData', '测试')
</script>

3. 双向绑定:defineModel(Vue3.4+ 新特性)

极简实现组件双向绑定,无需 props+emit 繁琐写法,封装弹窗、输入框组件必备。

// 子组件【正确写法】Vue3.4+ defineModel 极简双向绑定
<script setup>
const modelValue = defineModel()
</script>

<template>
  <input v-model="modelValue" placeholder="双向绑定输入" />
</template>

// 父组件使用
<script setup>
const inputVal = ref('')
</script>
<template>
  <Child v-model="inputVal" />
</template>

旧写法(繁琐不推荐) :传统props+emit冗余实现双向绑定

// 老旧冗余写法,现已废弃
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const change = (val) => {
  emit('update:modelValue', val)
}

4. 祖先后代通信:provide / inject

跨多层级组件传值,无需逐层透传,适合全局配置、主题、权限、用户信息透传。

// 【正确】祖先组件注入数据
<script setup>
import { provide, ref } from 'vue'
// 传递响应式数据,后代可联动更新
const theme = ref('dark')
provide('theme', theme)
provide('userName', '超级管理员')
</script>

// 【正确】任意后代组件接收
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const userName = inject('userName')
</script>

错误写法踩坑:传递普通静态值,后代无法响应更新

<script setup>
// 错误:传递普通字符串,非响应式,修改不联动
provide('theme', 'light')
</script>

5. 组件实例获取:defineExpose

Vue3 组件默认关闭实例暴露,父组件想要调用子组件方法、获取子组件数据,必须通过 defineExpose 主动暴露。

// 子组件【正确写法】主动暴露实例和方法
<script setup>
const msg = '子组件数据'
const childFn = () => console.log('执行子组件方法')

// 主动暴露,父组件才可调用
defineExpose({ msg, childFn })
</script>

// 父组件【正确写法】
<script setup>
import { ref } from 'vue'
const childRef = ref(null)

const callChild = () => {
  childRef.value.childFn()
}
</script>

<template>
  <Child ref="childRef" />
</template>

错误写法踩坑:不写defineExpose,父组件获取不到子组件数据和方法

<script setup>
// 子组件未暴露任何内容
const childFn = () => {}
// 父组件调用直接报错
// childRef.value.childFn() 【undefined】
</script>

6. 全局状态通信:Pinia

Vue3 官方替代 Vuex 的状态管理库,轻量化、简洁、模块化、无嵌套,支持 TS、自动持久化,是项目全局状态共享首选方案。

// 1. 新建 store/user.js 状态仓库
import { defineStore } from 'pinia'

// 定义用户全局仓库
export const useUserStore = defineStore('user', {
  // 状态
  state: () => ({
    userName: '',
    token: '',
    userId: ''
  }),
  // 计算属性
  getters: {
    isLogin: (state) => !!state.token
  },
  // 同步/异步方法
  actions: {
    // 存储用户信息
    setUserInfo(data) {
      this.userName = data.userName
      this.token = data.token
      this.userId = data.userId
    },
    // 清空用户信息
    clearUserInfo() {
      this.$reset()
    }
  }
})
// 2. 组件内使用 Pinia 状态
<script setup>
import { useUserStore } from '@/store/user'
// 初始化仓库
const userStore = useUserStore()

// 赋值修改全局状态
const setUser = () => {
  userStore.setUserInfo({
    userName: 'Vue3开发者',
    token: 'xxxx-xxxx-xxxx',
    userId: '10001'
  })
}

// 清空状态
const clearUser = () => {
  userStore.clearUserInfo()
}
</script>

<template>
  <div>
    <p>用户名:{{ userStore.userName }}</p>
    <button @click="setUser">登录赋值</button>
    <button @click="clearUser">清空信息</button>
  </div>
</template>

7. 插槽通信:slot / 作用域插槽

默认插槽、具名插槽实现内容分发,作用域插槽实现子传父数据渲染,高阶组件封装必备。

// 子组件
<template>
  <!-- 作用域插槽向外传递数据 -->
  <slot :msg="hello vue3"></slot>
</template>

// 父组件
<template>
  <Child v-slot="scope">
    {{ scope.msg }}
  </Child>
</template>

8. 兄弟组件通信:Pinia / 自定义事件

Vue3 不推荐事件总线,统一使用 Pinia 实现兄弟组件状态共享,稳定易维护。


五、Vue3 响应式原理核心考点(面试重中之重)

1. 底层原理 Proxy

Vue3 使用Proxy + Reflect 实现响应式,替代 Vue2 Object.defineProperty,解决大量底层缺陷。

2. Proxy 对比 defineProperty 优势

  • 可监听数组新增、删除、下标修改、长度修改
  • 可监听对象新增、删除属性
  • 支持批量拦截、更完善的拦截能力
  • 无需递归遍历初始对象,性能更优

3. 三大核心机制

  • 依赖收集:数据读取时收集当前组件渲染副作用
  • 依赖追踪:数据变更触发对应的更新函数
  • 派发更新:通知视图更新、执行监听与计算属性回调

4. 响应式丢失常见场景

  • reactive对象直接解构:丢失响应式
  • 直接替换reactive对象:丢失响应式
  • 数组下标/长度直接修改:部分场景不更新视图
  • 普通函数接收响应式数据:丢失响应式绑定
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({ a: 1, b: 2 })

// ========== 错误写法(全部踩坑)==========
// 坑1:直接解构,丢失响应式
// const { a } = state

// 坑2:直接替换整个对象,响应式销毁
// state = reactive({ a: 100 })

// 坑3:数组下标直接修改,部分场景不更新视图
// const arr = reactive([1,2,3])
// arr[0] = 99

// ========== 正确写法(规范稳定)==========
const { a, b } = toRefs(state)
a.value = 99 // 响应式正常更新

六、Vue3 编译与性能优化知识点

1. 虚拟 DOM 重写

Vue3 重构虚拟 DOM,优化 diff 算法,对比层级更精准、补丁更少、更新更快。

2. 静态提升

编译阶段将静态不变节点提升到渲染函数外部,避免每次渲染重新创建 VNode,大幅提升性能。

3. 预字符化

连续静态文本合并为常量字符串,减少虚拟 DOM 节点数量,降低内存占用。

4. 缓存事件处理函数

Vue3 自动缓存模板事件函数,避免每次更新生成新函数,减少不必要的 diff 更新。

5. 按需引入 Tree-Shaking

Vue3 全量模块化导出,未使用的 API 可被打包工具剔除,大幅缩减打包体积。


七、Vue3 高频实用新特性知识点

  1. SFC 语法糖升级:script setup 语法,代码更简洁、无需手动 return、自动注册组件,简化组件开发逻辑
  2. CSS 变量注入:v-bind() 可在 CSS 中使用 JS 变量,实现动态样式、主题切换、动态尺寸等高阶样式需求
<script setup>  
    import { ref } from 'vue'  
    // 定义JS动态变量  
    const textColor = ref('#4096ff') 
</script>  
<template>  
    <div class="box">Vue3动态样式</div> 
</template>  
<style scoped>  
.box { 
    color: v-bind(textColor);  
}  
</style>

错误写法踩坑:CSS 无法直接读取 JS 变量,不使用 v-bind 绑定,动态样式不生效

<style scoped>  
/* 错误:无法识别JS变量textColor */ 
.box {  
    color: textColor;  
}  
</style>
  1. 多根节点支持:Vue3 摒弃 Vue2 唯一根节点限制,默认支持 Fragment 虚拟片段,减少多余 DOM 层级,精简页面结构
  2. Teleport 传送门:脱离当前组件DOM层级,将节点挂载到任意指定DOM位置,完美解决弹窗、遮罩、悬浮层层级穿透问题
  3. Suspense 异步兜底:内置异步组件加载兜底方案,无需手动写loading状态,优化异步组件加载体验
  4. defineProps 默认值写法优化:Vue3.3+ 提供 withDefaults 语法糖,完美支持TS类型推导,简洁规范设置props默认值
  5. 自定义指令生命周期更新:Vue3 指令钩子与组件生命周期对齐,废弃Vue2旧钩子,逻辑更统一,避免执行异常

八、Vue3 开发高频避坑知识点

  • reactive 不支持基础类型,基础类型必须用 ref
  • reactive 解构直接丢失响应式,必须配合 toRefs
  • setup 中无 this,无法使用 Vue2 原型方法
  • 组件默认不暴露实例,必须 defineExpose 才可被父组件调用
  • watch 监听 reactive 对象必须开启 deep 深度监听
  • 异步请求写在 onMounted,不要写在 setup 同步顶层大量逻辑
  • 定时器、事件监听必须在 onUnmounted 中清除,防止内存泄漏
  • script setup 中组件自动注册,但全局组件仍需全局注册

九、知识点总结

本次复盘汇总的 30+ Vue3 核心知识点,覆盖:

  • 基础语法:ref / reactive / computed / watch
  • 生命周期与组件执行机制
  • 八大组件通信方案
  • Proxy 响应式底层原理
  • 编译优化与性能提升机制
  • 高频新特性与实战避坑指南

这些知识点既是日常开发必备基础,也是面试高频核心考点,熟练掌握可以彻底打通 Vue3 知识体系,告别只会写业务不懂原理、知识点零散的问题。

Vue3 defineProps使用指南

defineProps是Vue3组合式API( < script setup > )中专用来声明组件接受父组件传值的宏函数,无须导入,直接使用。他的核心:声明子组件要接收的props、定义类型校验、设置默认值、必填项。

一、基础用法(最简单)

直接声明props名称数组,适合简单场景:

<!-- 子组件 Child.vue --> 
<script setup> 
// 基础用法:只声明名称 
defineProps(['title', 'count']) 
</script> 
<template>
    <div>{{ title }}</div>
    <div>{{ count }}</div>
</template>

父组件使用

<!-- 父组件 parent.vue -->
<Child title="我是标题" :cout="10" />

二、带校验的用法(推荐)

可以指定类型、必填、默认值,开发中很常见

<script setup> 
defineProps({ 
    // 基础类型校验 String/Number/Boolean/Array/Object/Function 
    title: { 
        type: String, 
        required: true, // 必填项 
        default: '默认标题' // 默认值 
    }, 
    count: { 
        type: Number, 
        default: 0 
    }, 
    // 多个可能的类型 
    id: [String, Number], 
    // 自定义校验函数 
    status: { 
        validator(value) { 
            // 必须是这几个值之一 
            return ['success', 'error', 'warning'].includes(value) 
        }
    } 
}) </script>

三、TS类型写法(Vue3+TypeScript)

如果用TS,推荐泛型写法,类型更安全

<script setup>
import {widthDefault} from "vue"
// 定义接口(推荐)
interface IProps {
    title: string
    count?: number  //可选
    list?: string[]
}

// 泛型+默认值
const props = withDefault(defineProps<IProps>(), {
    count: 0,
    list: ()=>[]
});
</script>

四、获取和使用props变量

widthDefault或者defineProps都会返回一个响应式对象,可以接收并使用

<script setup>
import { toRefs } from "vue"
// 接收 props 对象 
const props = defineProps(['title', 'count']);
// 1.直接使用props.xxx方式使用
console.log(props.title) 
console.log(props.count)

// 2.通过使用toRefs的方式解构props
// 注意不能直接结构props, 会丢失响应式
const {title, count} = toRefs(props);
console.log(title.value)
console.log(count.value)
</script>

TinyRobot Bubble:为 AI 对话而生的 Vue 3 消息气泡组件

本文由云软件体验技术团队胡靖原创。

在 AI 应用里,消息气泡看似只是 UI 的一小块,真正落地时却会快速变复杂:流式输出、Markdown、图片、多模态内容、推理过程、工具调用、消息分组、状态折叠、角色样式、自动滚动……这些能力如果都从零实现,往往会让业务代码被展示逻辑淹没。

TinyRobot 的 Bubble 组件正是为这个场景设计的。它不是一个简单的“文本气泡”,而是一套面向 AI 对话界面的消息展示系统,内置 BubbleBubbleListBubbleProvider 三个核心能力,让开发者可以从单条消息展示平滑扩展到完整对话流。

1.png

一行代码,展示一条 AI 消息

最基础的用法非常直接:

<template>
  <tr-bubble role="assistant" content="你好,我是 TinyRobot,可以帮助你快速构建 AI 对话界面。" placement="start" />
</template>

<script setup lang="ts">
import { TrBubble } from "@opentiny/tiny-robot";
</script>

Bubble 支持 placement 控制左右位置,支持 avatar 注入头像组件,也支持通过 CSS 变量调整背景、字号、圆角、宽度等视觉细节。对业务开发来说,这意味着你可以先快速搭出可用界面,再按产品设计逐步定制样式。

为流式输出准备的响应式内容

AI 回复通常不是一次性返回,而是逐 token、逐片段输出。Bubble 的 content 是响应式的,只要持续更新内容,组件就能自然呈现流式效果:

<template>
  <tr-bubble :content="streamContent" :avatar="aiAvatar" />
</template>

<script setup lang="ts">
import { TrBubble } from "@opentiny/tiny-robot";
import { IconAi } from "@opentiny/tiny-robot-svgs";
import { h, ref } from "vue";

const aiAvatar = h(IconAi, { style: { fontSize: "32px" } });
const streamContent = ref("");

async function startStream() {
  const text = "这是一段正在生成中的 AI 回复。";
  streamContent.value = "";

  for (const char of text) {
    streamContent.value += char;
    await new Promise((resolve) => setTimeout(resolve, 80));
  }
}
</script>

这类设计非常适合接入 SSE、Fetch Stream 或 TinyRobot Kit 的消息管理能力。展示层只关心消息对象如何变化,不需要把流式渲染逻辑塞进组件内部。

2.gif

不止文本:图片、Markdown、推理和工具调用

Bubble 的内容模型兼容常见的大模型消息结构。content 可以是字符串,也可以是数组内容项,例如图片:

<tr-bubble
  :content="[
    { type: 'text', text: '这是一张生成结果:' },
    { type: 'image_url', image_url: { url: imageUrl } },
  ]"
  content-render-mode="split"
/>

当内容项中出现 type: 'image_url' 时,Bubble 会自动命中内置图片渲染器。通过 contentRenderMode,可以选择把图文渲染在同一个气泡框内,或拆成多个独立 box。

对更复杂的 AI 模型输出,Bubble 也提供了内置渲染器:

  • Text:默认文本渲染
  • Image:图片内容渲染
  • Markdown:Markdown 内容渲染
  • Loading:加载状态渲染
  • Reasoning:推理过程渲染
  • Tools / Tool:工具调用渲染
  • ToolRole:tool 角色消息渲染

例如模型返回推理内容时,可以直接使用 reasoning_content

<tr-bubble :content="answer" :reasoning_content="reasoningContent" :state="{ thinking: false, open: true }" />

工具调用也可以用 OpenAI 风格的 tool_calls 结构表达:

const message = {
  role: "assistant",
  content: "我来查询天气。",
  tool_calls: [
    {
      id: "call_0",
      type: "function",
      function: {
        name: "get_weather",
        arguments: '{"city":"深圳"}',
      },
    },
  ],
  state: {
    toolCall: {
      call_0: { status: "running", open: true },
    },
  },
};

这让 Bubble 很适合构建 Agent、Copilot、企业知识库助手等需要展示“模型正在做什么”的产品。

3.gif

BubbleList:从单条气泡到完整对话流

实际业务不会只展示一条消息。BubbleList 接收 messages 数组,并通过 roleConfigs 统一配置不同角色的头像、位置、形状和隐藏策略:

<template>
  <tr-bubble-list :messages="messages" :role-configs="roleConfigs" auto-scroll />
</template>

<script setup lang="ts">
import type { BubbleListProps, BubbleRoleConfig } from "@opentiny/tiny-robot";
import { TrBubbleList } from "@opentiny/tiny-robot";

const messages: BubbleListProps["messages"] = [
  { role: "user", content: "帮我总结这份文档" },
  { role: "assistant", content: "可以,请上传文档。" },
];

const roleConfigs: Record<string, BubbleRoleConfig> = {
  user: { placement: "end" },
  assistant: { placement: "start" },
};
</script>

BubbleList 默认使用 divider 分组策略,以 user 作为分割点:用户消息单独成组,后续非用户消息合并为同一次回答。它也支持 consecutive 连续角色分组,或传入自定义分组函数。

这种默认策略很适合 AI 聊天结构:一次完整回答通常以 assistant 开始、以 assistant 结束,中间可能穿插一条或多条 tool 结果。

User
└─ 帮我查一下这个工单的 SLA 风险

Assistant 回答块
├─ assistant:我先查询工单详情,并发起 tool call
├─ tool:返回工单详情
├─ tool:返回 SLA 规则
└─ assistant:根据工具结果给出风险判断

User
└─ 那应该怎么处理?

Assistant 回答块
├─ assistant:我继续检查处理人和审批状态
├─ tool:返回处理人信息
└─ assistant:给出处理建议和注意事项

autoScroll 也针对聊天场景做了处理:当用户发送新消息时,列表会平滑滚动到底部;当 AI 内容持续更新时,只有在用户接近底部时才自动跟随,避免打断用户查看历史内容。

渲染器架构:扩展复杂内容,而不是重写组件

Bubble 最值得开发者关注的是它的渲染器机制。组件将渲染拆成两层:

  • Box 渲染器:控制气泡外层容器
  • Content 渲染器:控制具体内容,如文本、图片、Markdown、工具调用

通过 BubbleProvider,可以在组件树内统一配置匹配规则:

<tr-bubble-provider :content-renderer-matches="contentRendererMatches">
  <tr-bubble-list :messages="messages" />
</tr-bubble-provider>
import { BubbleRendererMatchPriority, type BubbleContentRendererMatch } from "@opentiny/tiny-robot";
import { markRaw } from "vue";
import SchemaCardRenderer from "./SchemaCardRenderer.vue";

const contentRendererMatches: BubbleContentRendererMatch[] = [
  {
    find: (_, content) => content.type === "schema_card",
    renderer: markRaw(SchemaCardRenderer),
    priority: BubbleRendererMatchPriority.CONTENT,
  },
];

这套机制让业务可以把订单卡片、审批卡片、知识库引用、图表结果等结构化内容接入 Bubble,而不用 fork 组件或在消息列表里写大量条件判断。

适合企业 AI 应用的状态边界

Bubble 的消息类型中包含 state 字段,专门用于存放 UI 状态,例如推理过程是否展开、工具调用详情是否展开、点赞状态等。组件通过 state-change 事件把状态变更抛给外层。

这种设计的好处是:消息内容仍然保持接近模型返回结构,UI 状态不会污染真正要发给模型或持久化的业务字段。

总结

TinyRobot Bubble 的价值不只是“好看的气泡”,而是把 AI 对话界面里高频、复杂、容易重复造轮子的展示能力沉淀成了一套可组合系统:

  • 单条消息用 Bubble
  • 完整对话流用 BubbleList
  • 全局渲染扩展用 BubbleProvider
  • 文本、图片、Markdown、推理、工具调用都有内置支持
  • 角色、分组、自动滚动、插槽、CSS 变量和 TypeScript 类型一并覆盖

如果你正在用 Vue 3 构建 AI Chat、Agent 控制台、企业知识库助手或 Copilot 类产品,TinyRobot Bubble 可以帮你把注意力从“消息怎么画”转移到“AI 能为用户完成什么”。

关于 OpenTiny NEXT

OpenTiny NEXT 是一套企业智能前端开发解决方案,以生成式 UI 和 WebMCP 两大核心技术为基础,对现有传统的 TinyVue 组件库、TinyEngine 低代码引擎等产品进行智能化升级,构建出面向 Agent 应用的前端 NEXT-SDKs、AI Extension、TinyRobot智能助手、GenUI等新产品,实现AI理解用户意图自主完成任务,加速企业应用的智能化改造。

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
TinyRobot 代码仓库:github.com/opentiny/ti… (欢迎star ⭐)

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!

手写 React 对比 VuReact 编译:真正省下来的是维护成本

📢 前言

很多人讨论 Vue 转 React,第一反应总是“能不能转”“转得快不快”“性能差多少”。

但如果你真的做过迁移,或者真的在 React 里维护过一批复杂组件,你很快会发现,最贵的往往不是第一次把组件写出来,而是之后每一次修改、交接、重构、补功能时,你还要不要重新审一遍 useCallbackuseMemo、依赖数组、事件回调和样式隔离。

所以这篇文章不讨论跑分,也不讨论玄学优化。我只想回答一个更实际的问题:

同一个组件,如果你手写 React,需要亲自维护的东西,是不是明显比“用 Vue 写输入,再交给 VuReact 编译”更多?

我的结论是:是,而且差距不小。VuReact 真正省下来的,不只是迁移动作本身,而是组件进入长期维护期之后,那些原本要由开发者脑补、手填、反复确认的成本。

比较口径说明

为了避免这篇文章变成情绪化宣传,我先把比较口径说清楚。

本文不比较运行时 benchmark,不比较“谁更现代”,也不假装手写 React 只有一种写法。这里比较的是典型工程实现下的维护成本,维度固定为:接口、回调、依赖、样板代码、样式隔离、运行时纯度。

维度 手写 React VuReact 编译路线
props 类型声明 需要手动设计和维护 defineProps / defineEmits 可映射为 TS 类型
事件回调 wiring 需要手动把事件改成 onXxx 编译阶段自动映射
Hook 依赖维护 需要开发者自己判断和补齐 编译阶段自动分析、自动注入
对象/数组 memo 判断 需要自己决定要不要包 useMemo 只对可分析的响应式表达式做优化
样式隔离处理 需要自己选方案并维护一致性 scoped 可直接落成带作用域标识的 CSS
最终产物纯度 取决于你的实现方式 输出就是纯 React,不带 Vue 运行时

也就是说,这篇文章不是在说“手写 React 不好”,而是在说:如果同样的业务目标可以用 Vue 输入 + VuReact 编译完成,那么你本来需要自己承担的维护义务,会少很多。

主证据样本:同一个组件,三种维护方式

我先拿一个综合样本来说话。这个样本不是极端 demo,而是很像真实业务组件:有 props、有 emits、有 ref、有 computed、有顶层箭头函数、有对象方法,还有 scoped 样式。

先看 Vue 输入。你会发现它本质上就是一个很正常的 Vue 3 组件,没有为了“迁移”刻意写成奇怪样子。

<template>
  <section class="counter-card">
    <h1>{{ props.title }}</h1>
    <h2>VuReact + Vue = React ({{ count }})</h2>
    <p>{{ title }}</p>
    <button @click="increment">+1</button>
    <button @click="methods.decrease">-1</button>
  </section>
</template>

<script setup lang="ts">
// @vr-name: HelloWorld
import { computed, ref, watch } from 'vue';

const props = defineProps<{ title?: string }>();
const emits = defineEmits<{ (e: 'update', value: number): void }>();

const step = ref(1);
const count = ref(0);
const title = computed(() => `阶数:x${step.value}`);

const increment = () => {
  count.value += step.value;
  emits('update', count.value);
};

const methods = {
  decrease() {
    count.value -= step.value;
    emits('update', count.value);
  },
};

watch(count, (newVal) => {
  step.value = Math.floor(newVal / 10) || 1;
});
</script>

<style scoped>
.counter-card { border: 1px solid #ddd; padding: 12px; }
</style>

如果这段逻辑让你手写成 React,一个很典型的等价实现,大概会长这样。注意,这不是“唯一正确写法”,而是一个工程上完全合理、也是多数团队都会接受的版本。

import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import './HelloWorld.css';

type IHelloWorldProps = {
  title?: string;
  onUpdate?: (value: number) => void;
};

const HelloWorld = memo((props: IHelloWorldProps) => {
  const [step, setStep] = useState(1);
  const [count, setCount] = useState(0);

  const title = useMemo(() => `阶数:x${step}`, [step]);

  const increment = useCallback(() => {
    setCount((prev) => {
      const next = prev + step;
      props.onUpdate?.(next);
      return next;
    });
  }, [step, props.onUpdate]);

  const methods = useMemo(
    () => ({
      decrease() {
        setCount((prev) => {
          const next = prev - step;
          props.onUpdate?.(next);
          return next;
        });
      },
    }),
    [step, props.onUpdate],
  );

  useEffect(() => {
    setStep(Math.floor(count / 10) || 1);
  }, [count]);

  return (
    <section className="counter-card">
      <h1>{props.title}</h1>
      <h2>VuReact + Vue = React ({count})</h2>
      <p>{title}</p>
      <button onClick={increment}>+1</button>
      <button onClick={methods.decrease}>-1</button>
    </section>
  );
});

再看 VuReact 的编译产物。这里最关键的不是“它也能跑”,而是它并没有牺牲 React 工程质量。你在 React 里想要的 memouseComputed/useVRefuseCallbackuseMemo、类型接口、样式作用域,它都完整落下来了。

import { useComputed, useVRef, useWatch } from '@vureact/runtime-core';
import { memo, useCallback, useMemo } from 'react';
import './HelloWorld-ebf8d8dc.css';

export type IHelloWorldProps = {
  title?: string;
} & {
  onUpdate?: (value: number) => void;
};

const HelloWorld = memo((props: IHelloWorldProps) => {
  const step = useVRef(1);
  const count = useVRef(0);
  const title = useComputed(() => `阶数:x${step.value}`);

  const increment = useCallback(() => {
    count.value += step.value;
    props.onUpdate?.(count.value);
  }, [count.value, step.value, props.onUpdate]);

  const methods = useMemo(
    () => ({
      decrease() {
        count.value -= step.value;
        props.onUpdate?.(count.value);
      },
    }),
    [count.value, step.value, props.onUpdate],
  );

  useWatch(count, (newVal) => {
    step.value = Math.floor(newVal / 10) || 1;
  });
});

这时候真正值得看的,不是“哪段代码更短”,而是“哪些维护动作必须由人来做”。按上面这个样本的可见代码统计:

指标 手写 React Vue 输入 + VuReact
显式优化 API 数量 5 处:memo、2 处 useMemouseCallbackuseEffect 0 处由开发者手写
需要手填的依赖数组项数量 6 项 0 项
与稳定性相关的样板代码行数 约 18 行 0 行由开发者额外维护
需要开发者主动判断的优化点数量 至少 5 个 0 个优化判断点

这个表的意义很直接:VuReact 不是帮你“少写一点 React 语法”,而是帮你少承担一整套组件级维护义务。你不用亲自决定标题该不该 useMemo,不用亲自判断回调依赖要不要补 onUpdate,也不用在每次改业务时重新审一遍数组是不是还正确。

次证据样本:连 slot 到 children 的接口翻译,也会更顺

如果只聊 Hook,你可能会以为这件事只是“少写几个依赖数组”。其实不是。组件接口设计本身,也会因为 VuReact 变得更顺。

以插槽为例,Vue 里的默认插槽会自然映射成 React 的 children,作用域插槽会映射成带参数的函数 children。也就是说,VuReact 帮你省掉的,不只是底层优化,还有内容分发接口的手工翻译成本。

例如:

<slot></slot> 会直接落成 props.children

<slot :item="item" :index="i"></slot> 会落成 props.children?.({ item, index })

这件事看起来小,实际在大型组件库里特别重要。因为你少做的不是一行改写,而是少做一次“我要把 Vue 的内容分发机制手工翻成 React 接口”的设计工作。对于需要交给别人继续维护的组件,这种接口自然度非常值钱。

工程上更关键的一点:产物是纯 React,不是套壳

很多“转换工具”最让人不放心的地方,不在于能不能跑,而在于它最后到底给你留下了什么。

VuReact 在这一点上的边界其实很清楚:官方文档明确强调,编译产物最终为纯 React 应用,不依赖 Vue 运行时,也不是在 React 中嵌入 Vue 容器的套壳方案。

这句话为什么重要?因为它直接决定了后续维护体验。

如果最终产物是双运行时桥接,短期也许能演示,但长期一定会出现调试复杂、性能归因困难、团队协作断层的问题。可如果最终产物就是标准 React 代码,那它就能直接进入你现有的 React 工具链、code review 流程和长期演进路径。

这也是为什么我更愿意用官网那四个词来概括 VuReact:语义感知、渐进迁移、约定驱动、完整特性适配。 它不是在做“表面可运行”,而是在做“可进入工程维护周期的 React 产物”。

为什么这对团队比对个人更重要

个人开发者感受到的是轻松,团队感受到的则是确定性。

对 code review 来说,少一些手工 memo 和依赖数组,意味着 review 的注意力可以更多放回业务本身,而不是反复检查“这里是不是漏依赖了”。对交接来说,新同事看到的是更稳定的输入约定和更标准的输出产物,而不是一堆高度依赖原作者经验的 React 小技巧。

对重构来说,成本差异更明显。手写 React 组件经常让人不敢轻动,因为你一改业务结构,就可能牵动 useMemouseCallbackuseEffect 的依赖关系。VuReact 让这类稳定性工作前移到编译阶段,本质上是在降低重构的心理门槛。

对迁移路线也是一样。你当然可以手写一个组件、十个组件,但当项目规模上来之后,真正难的不是有没有人会写 React,而是有没有办法把大量“手工判断”变成稳定流程。VuReact 的价值,恰恰就在这里。

下一步怎么验证

如果你想判断这是不是适合你的路线,最好的方法不是继续看宣传语,而是直接去看真实产物。

先看官网的 语义编译对照 和 “为什么选 VuReact”,确认它是不是你认同的工程思路;再看 GitHub 和在线演示,判断编译后的 React 项目是不是你愿意接手维护的样子;如果还想继续深挖,可以再读我前面写过的那篇 “证据链” 文章,专门看 Hook 和依赖数组那一层的负担差异。

官网GitHub在线演示(CRM)在线演示(Customer Support Hub)

💬 写在最后

VuReact 的初心一直没有变——让你用熟悉的 Vue 编写 React,同时让项目平滑迁移到 React 生态,降低迁移成本,保留开发体验

它是一款面向 Vue 转 React 编译工具,它能将 Vue 3 代码编译为标准、可维护的纯 React 。

🌐 Github:github.com/vureact-js/… 📃 官方文档:vureact.top

✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!Github 仓库点亮 Star ⭐!

前端优化实战指南(工程化/首屏/懒加载/Next.js等)

构建优化是前端优化的基础,也是性价比最高的优化方向——无需大量修改业务代码,就能显著降低打包体积、提升构建速度,适配不同环境的部署需求,同时适配 React、Vue2、Vue3 等主流框架。


一、工程化 & 构建优化(Webpack / Vite)

1.1 Webpack 优化(主流项目实战,适配 React/Vue2/Vue3)

1.1.1 减小打包体积(核心:tree-shaking + 代码分割 + 依赖优化)

  • 开启 tree-shaking:仅打包被使用的代码,剔除死代码。

    • 配置:mode: "production"(默认开启),配合 package.json"sideEffects": false(需确认第三方依赖无副作用,若有则单独配置,如 ["*.css", "*.less", "*.scss"])。
    • 注意:仅对 ES6 模块(import/export)有效,CommonJS 模块(require)无法触发 tree-shaking,需避免混用模块规范;Vue2 项目需确保使用 vue-loader@15+ 版本,React 项目需避免使用 require 引入组件/工具。
  • 代码分割(Code Splitting):将代码拆分为多个 chunk,避免单文件过大,实现按需加载。

    • 路由分割

      • React 项目:使用 React.lazy + Suspense(函数组件),配合 react-router-dom 实现路由拆分,Suspense 需配置 fallback(加载占位),避免页面空白;类组件可使用 loadable-components 替代。

        // React 路由分割示例
        import { Suspense, lazy } from 'react';
        import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
        
        const Home = lazy(() => import('./pages/Home'));
        const About = lazy(() => import('./pages/About'));
        
        function App() {
          return (
            <Router>
              <Suspense fallback={<div>加载中...</div>}>
                <Routes>
                  <Route path="/" element={<Home />} />
                  <Route path="/about" element={<About />} />
                </Routes>
              </Suspense>
            </Router>
          );
        }
        
      • Vue2 项目:使用 vue-routercomponent: () => import('xxx'),配合 webpackChunkName 自定义 chunk 名称,便于调试。

        // Vue2 路由分割示例(vue-router@3.x)
        const router = new VueRouter({
          routes: [
            {
              path: '/',
              name: 'Home',
              component: () => import(/* webpackChunkName: "home" */ './views/Home.vue')
            },
            {
              path: '/about',
              name: 'About',
              component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
            }
          ]
        });
        
      • Vue3 项目:与 Vue2 用法一致,适配 vue-router@4.x,可结合 setup 语法使用,无额外配置差异。

    • 公共依赖分割splitChunks 配置,将第三方依赖(如 react、react-dom、vue、vue-router、axios)与业务代码分离,单独打包为 vendor chunk,利用浏览器缓存复用。

      // webpack.config.js
      optimization: {
        splitChunks: {
          chunks: 'all',
          cacheGroups: {
            vendor: {
              test: /[\\/]node_modules[\\/]/,
              name: 'vendors',
              priority: 10,
              reuseExistingChunk: true
            },
            common: {
              minSize: 30000,
              minChunks: 2,
              priority: 5,
              reuseExistingChunk: true
            }
          }
        }
      }
      
  • 依赖优化:剔除无用依赖 + 替换轻量依赖

    • 使用 webpack-bundle-analyzer 分析打包体积,找到体积过大的依赖。

    • 替换方案:moment.jsday.js(体积缩小 80%+)、lodashlodash-es(支持 tree-shaking)、jquery → 原生 DOM / 轻量库。

    • 按需引入:

      • React 生态:antd、Material-UI 等 UI 库,使用 babel-plugin-import 实现组件和样式的按需加载。
      • Vue2 生态:element-ui 使用 babel-plugin-import 按需引入。
      • Vue3 生态:element-plusant-design-vue@4+ 支持 babel-plugin-import 按需引入,也可通过 setup 语法自动按需引入组件。
      // .babelrc(React + antd 按需引入)
      {
        "plugins": [
          ["import", {
            "libraryName": "antd",
            "libraryDirectory": "es",
            "style": "css"
          }]
        ]
      }
      
      // .babelrc(Vue2 + element-ui 按需引入)
      {
        "plugins": [
          ["import", {
            "libraryName": "element-ui",
            "libraryDirectory": "lib",
            "style": true
          }]
        ]
      }
      
  • 资源压缩

    • JS 压缩:production 模式默认使用 TerserPlugin,可配置 parallel: true 开启多线程压缩。

    • CSS 压缩:使用 mini-css-extract-plugin 提取 CSS 为单独文件,配合 css-minimizer-webpack-plugin 压缩 CSS。

    • 图片压缩:使用 image-webpack-loader 压缩图片,配置 limit 限制小图片转为 base64(减少 HTTP 请求)。

      // module.rules 中配置
      {
        test: /\.(png|jpe?g|gif|svg)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
              name: 'static/img/[name].[hash:8].[ext]',
              esModule: false
            }
          },
          'image-webpack-loader'
        ]
      }
      

1.1.2 提升构建速度

  • 多线程构建:使用 thread-loader 将耗时的 loader(如 babel-loaderts-loader)放入单独线程。

    // React 项目配置
    {
      test: /\.(js|jsx|ts|tsx)$/,
      exclude: /node_modules/,
      use: [
        'thread-loader',
        {
          loader: 'babel-loader',
          options: {
            cacheDirectory: true
          }
        }
      ]
    }
    
    // Vue 项目配置(thread-loader 放在 vue-loader 之前)
    {
      test: /\.vue$/,
      exclude: /node_modules/,
      use: [
        'thread-loader',
        'vue-loader'
      ]
    }
    
  • 缓存优化:开启 loader 缓存(cacheDirectory)和 webpack 持久化缓存(cache: { type: 'filesystem' }),避免每次构建都重新编译。

  • 缩小构建范围exclude 排除 node_modulesdist 等无需编译的目录;include 明确指定需要编译的目录(如 src)。

  • 替换构建工具:若项目体积较大,可考虑将 Webpack 替换为 Vite(基于 ES Module,冷启动速度提升 10 倍+)。Vue3 项目优先使用 Vite,React 项目可使用 @vitejs/plugin-react 适配。

1.2 Vite 优化(新兴项目首选,适配 React/Vue2/Vue3)

Vite 本身已做了大量优化,核心优化方向是"减少不必要的编译":

  • 依赖预构建:Vite 自动预构建第三方依赖,生成 ESM 格式产物,可通过 optimizeDeps 自定义预构建范围。

  • 静态资源优化:内置图片、CSS 压缩,小图片自动转 base64,可通过 assetsInclude 配置。

  • 生产环境优化build 时默认开启 minify: 'terser',配置 rollupOptions 实现代码分割。

    // vite.config.js(Vue3 项目)
    import { defineConfig } from 'vite';
    import vue from '@vitejs/plugin-vue';
    
    export default defineConfig({
      plugins: [vue()],
      build: {
        minify: 'terser',
        rollupOptions: {
          output: {
            chunkFileNames: 'static/js/[name].[hash].js',
            entryFileNames: 'static/js/[name].[hash].js',
            assetFileNames: 'static/[ext]/[name].[hash].[ext]',
            manualChunks: {
              vendor: ['vue', 'vue-router', 'axios']
            }
          }
        }
      }
    });
    
    // vite.config.js(React 项目)
    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    
    export default defineConfig({
      plugins: [react()],
      build: {
        minify: 'terser',
        rollupOptions: {
          output: {
            chunkFileNames: 'static/js/[name].[hash].js',
            entryFileNames: 'static/js/[name].[hash].js',
            assetFileNames: 'static/[ext]/[name].[hash].[ext]',
            manualChunks: {
              vendor: ['react', 'react-dom', 'react-router-dom']
            }
          }
        }
      }
    });
    
  • 框架适配:Vue2 需安装 @vitejs/plugin-vue2;React 需安装 @vitejs/plugin-react(支持 Fast Refresh);Vue3 原生支持。


二、首屏加载优化(核心:减少加载时间,提升用户感知)

首屏加载速度直接影响用户留存,核心思路是"减少首屏资源体积、减少 HTTP 请求、优化资源加载顺序"。

2.1 资源层面优化

  • HTML 优化

    • 精简 HTML 结构,将首屏关键 CSS 内联到 <head>,JS 脚本放在 <body> 底部或使用 defer/async 属性。
    • React 项目:使用 react-dom/server 或 Next.js 实现服务端渲染,减少白屏时间。
    • Vue 项目:使用 vue-server-renderer(Vue2)、@vue/server-renderer(Vue3)或 Nuxt.js 实现 SSR。
  • CSS 优化

    • 提取首屏关键 CSS(Critical CSS)内联到 HTML,非关键 CSS 异步加载。
    • 使用 CSS Sprites 合并小图标,避免使用 @import 引入 CSS(会阻塞渲染)。
    • React 项目:使用 CSS Modules 或 Styled Components 避免样式冲突。
    • Vue 项目:使用 scoped 样式或 CSS Modules 减少样式冗余。
  • JS 优化

    • 减少首屏 JS 体积,非必要脚本(统计、广告)异步加载。
    • React 项目:使用 React.lazy + Suspense 拆分首屏组件,减少 useEffect 的不必要执行。
    • Vue2 项目:使用路由懒加载,用 v-if 替代 v-show(首屏不显示的组件不渲染)。
    • Vue3 项目:使用 setup 语法提升响应式效率,配合 Teleport 将非首屏组件挂载到主渲染树外。

2.2 框架专属首屏优化

2.2.1 React 项目

  • SSR/SSG:使用 Next.js,通过 getStaticProps(SSG)或 getServerSideProps(SSR)提前获取数据,首屏由服务端返回完整 HTML。
  • 预加载:使用 Next.js Link 组件的 prefetch 属性预加载路由;使用 dynamic import 动态加载非首屏组件。
  • 状态管理:首屏无需的状态延迟初始化,使用 useMemouseCallback 缓存计算结果和函数。

2.2.2 Vue2 项目

  • SSR:使用 Nuxt.js@2,通过 asyncDatafetch 提前获取首屏数据。
  • Vue 实例优化:避免在 createdmounted 中执行耗时操作,可延迟到 $nextTicksetTimeout
  • 组件优化:首屏组件精简,非首屏组件使用路由懒加载;避免使用 Vue.filter(全局过滤器会增加初始化时间)。

2.2.3 Vue3 项目

  • SSR/SSG:使用 Nuxt.js@3 或 VitePress,通过 useAsyncDatauseFetch 提前获取数据。
  • Composition API 优化setup 语法减少组件初始化时间;避免在 setup 中执行耗时操作,使用 onMounted 延迟执行。
  • 按需引入:Vue3 核心库可按需引入;Pinia 替代 Vuex(体积更小、性能更优)。

三、懒加载优化(通用+框架适配,减少首屏压力)

懒加载核心是"按需加载",仅当资源进入视口或即将进入视口时才加载。

3.1 图片懒加载

  • 原生懒加载:使用 <img loading="lazy"> 属性,浏览器原生支持,无需额外插件。不支持 IE,可做降级处理。

  • 插件懒加载(适配框架)

    • React 项目:使用 react-lazyload 或自定义 Hook(IntersectionObserver API)。

      // React 自定义懒加载 Hook
      import { useEffect, useRef, useState } from 'react';
      
      function useLazyLoad() {
        const ref = useRef(null);
        const [isVisible, setIsVisible] = useState(false);
      
        useEffect(() => {
          const observer = new IntersectionObserver(
            ([entry]) => setIsVisible(entry.isIntersecting),
            { threshold: 0.1 }
          );
          if (ref.current) observer.observe(ref.current);
          return () => {
            if (ref.current) observer.unobserve(ref.current);
          };
        }, []);
      
        return { ref, isVisible };
      }
      
      // 使用示例
      function LazyImage({ src, alt }) {
        const { ref, isVisible } = useLazyLoad();
        return (
          <div ref={ref}>
            {isVisible ? (
              <img src={src} alt={alt} />
            ) : (
              <div className="placeholder" />
            )}
          </div>
        );
      }
      
    • Vue2 项目:使用 vue-lazyload 插件。

      // Vue2 配置 vue-lazyload
      import Vue from 'vue';
      import VueLazyload from 'vue-lazyload';
      
      Vue.use(VueLazyload, {
        preLoad: 1.3,
        error: 'error.png',
        loading: 'loading.gif',
        attempt: 1
      });
      
      <!-- 组件中使用 -->
      <img v-lazy="imageUrl" />
      
    • Vue3 项目:使用 vue3-lazyload 插件。

      // Vue3 配置 vue3-lazyload
      import { createApp } from 'vue';
      import App from './App.vue';
      import VueLazyload from 'vue3-lazyload';
      
      const app = createApp(App);
      app.use(VueLazyload, {
        preLoad: 1.3,
        error: 'error.png',
        loading: 'loading.gif'
      });
      app.mount('#app');
      
      <!-- 组件中使用 -->
      <img v-lazy="imageUrl" />
      
  • 注意事项:懒加载图片需设置宽高避免布局抖动;优先使用 WebP 格式(体积更小)并做降级处理;首屏可见的图片不要使用懒加载。

3.2 组件/路由懒加载

3.2.1 React 组件/路由懒加载

  • 路由懒加载:使用 React.lazy + Suspense(参见 1.1.1)。

  • 组件懒加载:非首屏组件使用 dynamic import 动态加载。

    // React 组件懒加载示例
    import { Suspense, lazy, useState } from 'react';
    
    const ModalComponent = lazy(() => import('./components/ModalComponent'));
    
    function Home() {
      const [showModal, setShowModal] = useState(false);
      return (
        <div>
          <button onClick={() => setShowModal(true)}>打开弹窗</button>
          {showModal && (
            <Suspense fallback={<div>加载中...</div>}>
              <ModalComponent onClose={() => setShowModal(false)} />
            </Suspense>
          )}
        </div>
      );
    }
    

3.2.2 Vue2/Vue3 组件/路由懒加载

  • 路由懒加载:使用 component: () => import('xxx')(参见 1.1.1)。

  • 组件懒加载:

    // Vue2 组件懒加载
    export default {
      components: {
        LazyComponent: () => import(
          /* webpackChunkName: "lazy-component" */
          './LazyComponent.vue'
        )
      }
    };
    
    // Vue3 组件懒加载(setup 语法)
    import { defineAsyncComponent } from 'vue';
    
    const LazyComponent = defineAsyncComponent(
      () => import('./LazyComponent.vue')
    );
    
    export default {
      components: { LazyComponent }
    };
    

3.3 第三方资源懒加载

  • 第三方脚本(统计、广告、地图)异步加载,避免阻塞首屏渲染。

    // 动态加载第三方脚本
    function loadScript(url, callback) {
      var script = document.createElement('script');
      script.src = url;
      script.async = true;
      script.onload = callback;
      document.body.appendChild(script);
    }
    
    // 页面加载完成后再加载统计脚本
    window.addEventListener('load', function () {
      loadScript('https://analytics.example.com/sdk.js', function () {
        console.log('统计脚本加载完成');
      });
    });
    
  • React/Vue 项目:第三方组件(如 echarts)使用懒加载引入,避免首屏加载冗余资源。


四、Next.js 优化(React 框架专属)

Next.js 内置了大量优化特性,在此基础上补充实战优化方案。

4.1 渲染模式优化

  • SSG(静态站点生成):适用于静态页面(官网、文档),构建时生成 HTML,可部署到 CDN,首屏最快。

  • SSR(服务端渲染):适用于动态页面(用户中心、数据看板),每次请求由服务端渲染,SEO 友好。

  • ISR(增量静态再生):结合 SSG 和 SSR,构建时生成静态页面,定期重新生成。

    // Next.js ISR 示例
    export async function getStaticProps() {
      const res = await fetch('https://api.example.com/news');
      const data = await res.json();
      return {
        props: { data },
        revalidate: 60  // 每 60 秒重新生成页面
      };
    }
    

4.2 路由优化

  • 路由预加载Link 组件默认预加载视口内的路由(prefetch: true),可手动预加载。

    import Link from 'next/link';
    import { useRouter } from 'next/router';
    
    function Home() {
      const router = useRouter();
    
      const preloadAbout = () => {
        router.prefetch('/about');
      };
    
      return (
        <div>
          <Link href="/about" prefetch={true}>关于我们</Link>
          <button onClick={preloadAbout}>预加载关于我们</button>
        </div>
      );
    }
    
  • 动态路由优化:使用 getStaticPaths 配置预渲染路径;大量动态路径可设置 fallback: true,未预渲染的路径由服务端实时渲染。

4.3 资源优化

  • 图片优化:使用 Next.js 内置 Image 组件,自动压缩、格式转换、懒加载。

    import Image from 'next/image';
    
    function Home() {
      return (
        <Image
          src="/images/hero.jpg"
          alt="首页封面"
          width={1200}
          height={600}
          loading="lazy"
          quality={80}
        />
      );
    }
    
  • 字体优化:使用 Next.js 内置 Font 组件,预加载字体,避免 FOIT(字体闪烁)。

  • 脚本优化:使用 Script 组件,支持 beforeInteractiveafterInteractivelazyOnload 等加载策略。

4.4 运行时优化

  • 数据缓存:使用 SWR 或 React Query 缓存数据,减少重复请求。
  • 组件优化:使用 React.memouseMemouseCallback 避免不必要的重渲染。
  • 打包优化:通过 next.config.js 配置 optimization,开启代码分割和依赖优化。

五、运行时优化(React/Vue2/Vue3 通用)

运行时优化核心是"减少重渲染、提升交互响应速度"。

5.1 React 运行时优化

  • 减少重渲染

    import { memo, useMemo, useCallback, useState } from 'react';
    
    // 子组件:使用 memo 包裹,避免无意义重渲染
    const Child = memo(({ name, onClick }) => {
      console.log('子组件渲染');
      return <button onClick={onClick}>{name}</button>;
    });
    
    // 父组件
    function Parent() {
      const [count, setCount] = useState(0);
      const name = useMemo(() => `用户${count}`, [count]);
      const handleClick = useCallback(() => {
        console.log('点击事件');
      }, []);
    
      return (
        <div>
          <button onClick={() => setCount(count + 1)}>计数:{count}</button>
          <Child name={name} onClick={handleClick} />
        </div>
      );
    }
    
  • 事件优化:避免在 render 中创建内联函数,使用 useCallback 缓存事件处理函数;大量列表使用事件委托。

  • 数据处理优化:大量数据使用虚拟列表(react-windowreact-virtualized);耗时计算使用 Web Worker。

5.2 Vue2 运行时优化

  • 减少重渲染

    • 使用 v-once 只渲染一次不再更新的内容。
    • 使用 v-if 替代 v-show(不常显示的组件不创建 DOM)。
    • 减少 watch 监听范围,使用 computed 缓存计算属性。
  • 组件优化:拆分大型组件;使用 keep-alive 缓存路由组件。

    <!-- Vue2 keep-alive 示例 -->
    <keep-alive>
      <router-view v-if="$route.meta.keepAlive" />
    </keep-alive>
    <router-view v-if="!$route.meta.keepAlive" />
    
    // 路由配置中设置 keepAlive
    const routes = [
      {
        path: '/home',
        component: Home,
        meta: { keepAlive: true }
      },
      {
        path: '/about',
        component: About,
        meta: { keepAlive: false }
      }
    ];
    
  • 数据优化:大量数据使用虚拟列表(vue-virtual-scroller);避免在 created/mounted 中执行耗时操作。

5.3 Vue3 运行时优化

  • 减少重渲染

    • 使用 refreactive 替代 Vue2 的 data,响应式效率更高。
    • 使用 computed 缓存计算属性。
    • 使用 watchEffect 替代 watch,自动追踪依赖。
    • 使用 definePropsdefineEmits 明确组件接口。
  • 组件优化keep-alive 缓存组件;Teleport 将弹窗等组件挂载到指定节点;拆分大型组件。

  • 数据优化:虚拟列表使用 vue-virtual-scroller@next;使用 toReftoRefs 避免解构导致响应式丢失;耗时计算使用 Web Worker。


六、网络 & 缓存优化(通用)

6.1 网络优化

  • HTTP 协议:使用 HTTPS;升级到 HTTP/2(多路复用、头部压缩)。
  • CDN 加速:静态资源部署到 CDN,用户就近获取。
  • 接口优化
    • 合并接口请求,避免重复请求。
    • 分页加载,避免一次性获取大量数据。
    • 使用接口缓存(localStorage/sessionStorage)缓存不常变化的数据。
    • React 可使用 SWR/React Query,Vue 可使用 vue-query。

6.2 缓存优化

  • 浏览器缓存

    • 强缓存:Cache-Control: public, max-age=86400,浏览器直接使用本地缓存。
    • 协商缓存:ETag/Last-Modified,强缓存过期后服务器判断资源是否更新,未更新返回 304。
  • 前端缓存

    • localStorage:持久化存储不常变化的数据。
    • sessionStorage:会话级临时数据。
    • Service Worker:缓存静态资源,实现离线访问(PWA),可通过 workbox 快速配置。
  • 缓存更新策略:静态资源使用哈希命名(app.[hash].js),资源更新时哈希变化触发重新请求;HTML 文件不缓存或短时间缓存,确保能获取最新资源引用。


七、总结

前端优化是系统性工作,需结合项目场景(React/Vue2/Vue3/Next.js)和业务需求,从工程化构建、首屏加载、懒加载、运行时、网络缓存等多个层面入手。

优先级建议:构建优化(tree-shaking、代码分割)> 路由懒加载 > 图片优化 > 首屏 SSR/SSG > 运行时优化 > 网络缓存优化。

验证工具:Lighthouse、Chrome DevTools Performance 面板、webpack-bundle-analyzer,持续检测优化效果。

vue3+lodash+ts+tailwin 实现多行文本的展开收起代码(支持渲染html)

07a7ae24fef487dbb588a967f3803000.jpg

546cc5d2086a8d3c425bd07710220ae9.jpg

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { debounce } from 'lodash-es'

interface Props {
  text: string
  maxLines?: number
  expandText?: string
  collapseText?: string
  expandClass?: string
  collapseClass?: string
}

const props = withDefaults(defineProps<Props>(), {
  maxLines: 3,
  expandText: '展开',
  collapseText: '收起',
  expandClass: 'text-blue-500',
  collapseClass: 'text-blue-500',
})

const containerRef = ref<HTMLElement>()
const expanded = ref(false)
const isTruncated = ref(false)
const truncatedHtml = ref(props.text)

// ─── HTML 工具 ───────────────────────────────────────────────

/** 块级标签集合:仅这些标签会被认为产生新行,用于"在最后一行末尾追加"判断 */
const BLOCK_TAGS = new Set([
  'DIV', 'P', 'SECTION', 'ARTICLE', 'BLOCKQUOTE',
  'LI', 'UL', 'OL', 'PRE',
  'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
])

/**
 * 把 suffixHtml 注入到 html 的「最深一个块级容器」内部末尾,
 * 保证它与最后一行可见文字处于同一内联流。
 * 碰到 <strong> 等行内元素会停住,避免继承粗体等样式。
 */
function appendInsideLastBlock(html: string, suffixHtml: string): string {
  const wrapper = document.createElement('div')
  wrapper.innerHTML = html
  let target: Element = wrapper
  while (target.lastElementChild && BLOCK_TAGS.has(target.lastElementChild.tagName)) {
    target = target.lastElementChild
  }
  target.insertAdjacentHTML('beforeend', suffixHtml)
  return wrapper.innerHTML
}

/** 把 HTML 字符串转成纯文本(保留换行语义) */
function htmlToPlainText(html: string): string {
  const div = document.createElement('div')
  div.innerHTML = html
  // <br> / <p> / <div> 换成换行,方便行高量测一致
  div.querySelectorAll('br').forEach(br => br.replaceWith('\n'))
  div.querySelectorAll('p, div').forEach(el => {
    el.prepend('\n')
  })
  return div.innerText ?? div.textContent ?? ''
}

/**
 * 将"纯文本截断到第 visibleLen 个字符"映射回原始 HTML,
 * 返回一段合法闭合的 HTML 片段。
 *
 * 思路:遍历原始 HTML 字符,跳过标签字符,只计可见字符数;
 * 找到第 visibleLen 个可见字符在原始字符串中的位置后截断,
 * 再用 DOMParser 补全未闭合标签。
 */
function sliceHtmlByVisibleLen(html: string, visibleLen: number): string {
  let visible = 0
  let i = 0
  let inTag = false

  while (i < html.length && visible < visibleLen) {
    const ch = html[i]
    if (ch === '<') {
      inTag = true
    } else if (ch === '>') {
      inTag = false
    } else if (!inTag) {
      visible++
    }
    i++
  }

  // i 现在指向截断位置(继续把当前标签走完,避免截断在标签内部)
  if (inTag) {
    const closeIdx = html.indexOf('>', i)
    i = closeIdx === -1 ? html.length : closeIdx + 1
  }

  const raw = html.slice(0, i)

  // 用 DOMParser 补全未闭合标签
  const doc = new DOMParser().parseFromString(raw, 'text/html')
  return doc.body.innerHTML
}

// ─── 样式量测 ────────────────────────────────────────────────

function getLineHeight(el: HTMLElement): number {
  const lh = parseFloat(getComputedStyle(el).lineHeight)
  return isNaN(lh) ? parseFloat(getComputedStyle(el).fontSize) * 1.5 : lh
}

function createMeasureEl(el: HTMLElement, width: number): HTMLDivElement {
  const cs = getComputedStyle(el)
  const div = document.createElement('div')
  div.style.cssText = `
    position: absolute;
    visibility: hidden;
    pointer-events: none;
    width: ${width}px;
    font-size: ${cs.fontSize};
    font-family: ${cs.fontFamily};
    font-weight: ${cs.fontWeight};
    line-height: ${cs.lineHeight};
    letter-spacing: ${cs.letterSpacing};
    word-break: ${cs.wordBreak};
    white-space: ${cs.whiteSpace};
  `
  document.body.appendChild(div)
  return div
}

// ─── 截断计算 ────────────────────────────────────────────────

function calcTruncation() {
  const el = containerRef.value
  if (!el || expanded.value) return

  const cs = getComputedStyle(el)
  const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)
  if (width <= 0) return

  const lineHeight = getLineHeight(el)
  const maxHeight = lineHeight * props.maxLines
  const measureEl = createMeasureEl(el, width)

  // 用 innerHTML 量高,与实际渲染一致
  measureEl.innerHTML = props.text
  const fullHeight = measureEl.scrollHeight

  if (fullHeight <= maxHeight+1) {
    document.body.removeChild(measureEl)
    isTruncated.value = false
    truncatedHtml.value = props.text
    return
  }

  isTruncated.value = true

  // 二分搜索操作纯文本字符数
  const plain = htmlToPlainText(props.text)
  const suffix = `...${props.expandText}x` // 占位 x 抵消 ml-0.5 偏差

  let lo = 0
  let hi = plain.length

  while (lo < hi) {
    const mid = Math.floor((lo + hi + 1) / 2)
    const slicedHtml = sliceHtmlByVisibleLen(props.text, mid)
    // 把 suffix 注入到最后一个块级容器内部,量测才会跟实际渲染一致
    measureEl.innerHTML = appendInsideLastBlock(slicedHtml, suffix)
    if (measureEl.scrollHeight <= maxHeight+1) {
      lo = mid
    } else {
      hi = mid - 1
    }
  }

  document.body.removeChild(measureEl)
  truncatedHtml.value = sliceHtmlByVisibleLen(props.text, lo)
}

// ─── 生命周期 & 侦听 ─────────────────────────────────────────

const debouncedCalc = debounce(calcTruncation, 100)
let resizeObserver: ResizeObserver | null = null

onMounted(() => {
  nextTick(() => {
    calcTruncation()
    if (containerRef.value) {
      resizeObserver = new ResizeObserver(debouncedCalc)
      resizeObserver.observe(containerRef.value)
    }
  })
})

onUnmounted(() => {
  resizeObserver?.disconnect()
  debouncedCalc.cancel()
})

watch(
  () => [props.text, props.maxLines],
  () => {
    expanded.value = false
    nextTick(calcTruncation)
  },
)

// ─── 展开 / 收起 ─────────────────────────────────────────────

function expand() {
  expanded.value = true
}

function collapse() {
  expanded.value = false
  nextTick(calcTruncation)
}

// ─── 最终渲染 HTML ───────────────────────────────────────────

/**
 * 把按钮 HTML 注入到内容末尾。
 * 展开态:全文 + 收起按钮
 * 收起态:截断 HTML + ...展开按钮
 */
const btnClass = computed(() =>
  `inline ml-0.5 cursor-pointer bg-transparent border-none p-0 [font-family:inherit] [font-size:inherit] [line-height:inherit]`,
)

const renderedHtml = computed(() => {
  if (expanded.value) {
    const collapseBtn =
      `<button class="${btnClass.value} ${props.collapseClass}"
               onclick="this.dispatchEvent(new CustomEvent('collapse', { bubbles: true }))"
       >${props.collapseText}</button>`
    return appendInsideLastBlock(props.text, collapseBtn)
  }
  if (isTruncated.value) {
    const expandBtn =
      `...<button class="${btnClass.value} ${props.expandClass}"
                  onclick="this.dispatchEvent(new CustomEvent('expand', { bubbles: true }))"
       >${props.expandText}</button>`
    return appendInsideLastBlock(truncatedHtml.value, expandBtn)
  }
  return props.text
})
</script>

<template>
  <div
    ref="containerRef"
    v-html="renderedHtml"
    @expand="expand"
    @collapse="collapse"
  />
</template>

Vue 的 :deep/:global/:slotted 怎么转成 React ?一份对照指南?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 作用域样式中的穿透选择器(:deep/:global/:slotted)经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉样式 :deep/:global/:slotted 的用法。

编译对照

:global():声明全局样式

:global() 用于在 scoped 样式中声明一段不受作用域限制的全局样式。VuReact 的处理方式:移除 :global() 包装,保留内部选择器原样输出

  • Vue 代码:
<!-- Component.vue -->
<template>
  <div class="component">
    <div class="global-class">全局类</div>
  </div>
</template>

<style scoped>
.component {
  :global(.global-class) {
    color: green;
  }
}
</style>
  • VuReact 编译后 CSS:
/* component-abc123.css */
.component[data-css-abc123] {
  .global-class {
    color: green;
  }
}

从示例可以看到::global(...) 被完全移除,内部的选择器照常展开,且不添加 scope 属性。这样 .global-class 就是一个全局可用的样式类。


:deep():样式穿透

:deep() 是 scoped 样式中最常用的穿透选择器,用于让父组件的样式能够影响子组件内部的元素。VuReact 的处理策略是::deep(...) 左侧的选择器加上 scope,右侧(:deep 内部)的选择器保持原样

在嵌套规则中使用 :deep()

  • Vue 代码:
<!-- Component.vue -->
<template>
  <div class="component">
    <div class="nested-component">深层嵌套组件</div>
  </div>
</template>

<style scoped>
.component {
  :deep(.nested-component) {
    background: yellow;
  }
}
</style>
  • VuReact 编译后 CSS:
/* component-abc123.css */
.component[data-css-abc123] {
  & .nested-component {
    background: yellow;
  }
}

从示例可以看到:在嵌套规则中,:deep() 左侧是 .component(加 scope),右侧 .nested-component(不加 scope)。

在单行规则中使用 :deep()

:deep() 也可以在非嵌套的单行规则中使用,左侧部分仍然被 scoped。

  • Vue 代码:
<style scoped>
.parent :deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] .btn { color: red; }

:deep() 紧贴选择器

  • Vue 代码:
<style scoped>
.parent:deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] .btn { color: red; }

带组合器的 :deep()

  • Vue 代码:
<style scoped>
.parent > :deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] > .btn { color: red; }

:deep() 作为选择器起始

:deep() 位于选择器最左侧时(无左侧部分),VuReact 会直接用 [scopeId] 作为左侧。

  • Vue 代码:
<style scoped>
:deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
[data-css-abc123] .btn { color: red; }

处理逻辑:左侧为空时,用 [data-css-abc123] 自身作为 scoped 占位。

:deep() 展开逗号选择器

:deep() 内部可以包含多个逗号分隔的选择器,VuReact 会逐一展开。

  • Vue 代码:
<style scoped>
.a :deep(.x, .y) { color: red; }
</style>
  • VuReact 编译后 CSS:
.a[data-css-abc123] .x, .a[data-css-abc123] .y { color: red; }

从示例可以看到::deep(.x, .y) 被展开为两个独立的选择器 .x.y,各自与左侧 .a[data-css-abc123] 拼接。


4. :slotted():插槽样式

:slotted() 用于为插槽传入的内容设置样式,VuReact 当前的处理方式是简单解包

  • Vue 代码:
<style scoped>
.component {
  :slotted(.slotted-content) {
    display: flex;
  }
}
</style>
  • VuReact 编译后 CSS:
.component[data-css-abc123] {
  .slotted-content {
    display: flex;
  }
}

从示例可以看到::slotted(...) 被移除,内部选择器 .slotted-content 保留,但不加 scope。完整的 :slotted() 语义支持仍在解决中。


复杂选择器共存

在一个组件中,:global:deep:slotted 可以与标准 scoped 选择器以及伪类(:hover::before 等)混合使用。

  • Vue 代码:
<style scoped>
.component {
  &:hover { opacity: 0.8; }
  &.active { font-weight: bold; }
  :global(.global-class) { color: green; }
  :deep(.nested-component) { background: yellow; }
  :slotted(.slotted-content) { display: flex; }
  &:not(:first-child) { margin-top: 20px; }
  &:nth-child(2n) { background: #f0f0f0; }
  &::before { content: '→'; }
  &::placeholder { color: gray; }
}
</style>
  • VuReact 编译后 CSS:
.component[data-css-abc123] {
  &:hover { opacity: 0.8; }
  &.active { font-weight: bold; }
  .global-class { color: green; }
  & .nested-component { background: yellow; }
  .slotted-content { display: flex; }
  &:not(:first-child) { margin-top: 20px; }
  &:nth-child(2n) { background: #f0f0f0; }
  &::before { content: '→'; }
  &::placeholder { color: gray; }
}

共处规则

选择器类型 行为 scope 注入
标准选择器 尾部追加 [data-css-xxx]
伪类/属性选择器 保持原样,插入 scope 在其之前
:global(...) 移除包装,内部不加 scope
:deep(...) 左侧加 scope,内部不加
:slotted(...) 移除包装,内部不加 scope ⚠️(待完善)

编译策略总结

VuReact 的作用域样式穿透选择器编译策略展示了完整的 scoped 选择器转换能力

  1. :global() 转换:移除 :global(...) 包装,内部选择器按全局样式输出,不加 scope
  2. :deep() 转换:将选择器按 :deep(...) 位置切割,左侧加 scope,内部保持穿透能力,支持嵌套、组合器、逗号展开等复杂场景
  3. :slotted() 转换:移除 :slotted(...) 包装,内部选择器保持原样(完整语义实现 WIP)
  4. 伪类兼容:hover::before:not():nth-child() 等伪类保持原样,scope 只插入在伪类之前
  5. 嵌套兼容:与 SCSS/Less 的 & 嵌套语法协作良好

支持的穿透选择器

选择器 状态 说明
:deep() ✅ 完整支持 左侧 scoped + 右侧穿透
:global() ✅ 完整支持 移除包装,全局样式
:slotted() ⚠️ 部分支持 解包处理,完整语义待完善

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移。编译后的 CSS 选择器既保持了 Vue scoped 样式的作用域隔离语义,又能通过 :deep():global() 灵活控制样式穿透范围,让迁移后的应用保持完整的 scoped 样式能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

9.响应式系统演进:effectScope 的作用与实现原理(Vue3.2)

前言

effectScope 是 Vue3.2 引入的一个强大响应式副作用管理工具,用于自动收集在同一个作用域内的响应式副作用(effect),以便在需要的时候可以一起销毁这些响应式副作用(effect),防止内存泄漏和意外行为。effectScope 简化了复杂代码中的响应式副作用的管理,提高了代码的可维护性,同时,effectScope 还支持嵌套作用域和独立的子作用域,即隔离副作用,总的来说它主要作用为开发者提供了灵活的响应式副作用管理方式。

effectScope 是一个底层的高级进阶 API,对于普通应用开发者一般使用不到它,但如果我们想进阶,那么就必须了解它的实现原理。如果我们想实现一些基于 Vue3 响应式库 @vue/reactivity 的公共 Hooks 库,我们就有可能需要使用到 effectScope API, 比如 vueuse 就使用到了 effectScope API。同时如果我们想要了解 Vue3.2 以后的源码也必须要了解 effectScope 的实现原理,另外还有 Vue3 状态管理库 Pinia 的源码也使用到了 effectScope API。所以说我们还是非常有必要了解它的。

在 Vue RFC 也有对其详细的解释,也可以了解一下。

注意:本篇文章实现的代码例子是在第五篇的基础上的,所以你还没看第五篇,可以先学习第五篇的内容。

在 Vue3 中什么时候需要清除响应式副作用

现在我们要实现以下这样的一个计数功能:

image.png

我们具体要实现的功能就是按 + 按钮就累计加 1,点击 清除计算结果 按钮则清除计算结果,且我们希望再次点击 + 按钮的时候也不再进行计算。

HTML 部分的代码如下:

<div>计算结果:<span id="counter"></span></div>
<button id="add">+</button>
<button id="delete">清除计算结果</button>

功能实现部分代码如下:

// 获取真实 DOM
const counterEl = document.getElementById('counter')
const addEl = document.getElementById('add')
const delEl = document.getElementById('delete')

// 利用响应式创建数据
const count = ref(0)
// 利用响应式动态变更 DOM 内容
effect(() => {
    counterEl.textContent = count.value
})
// 添加
addEl.addEventListener('click', () => {
    count.value++
})
// 清除计算结果
delEl.addEventListener('click', () => {
    const parent = counterEl.parentNode
    parent.removeChild(counterEl)
})

值得注意的是我们清除计算结果是直接删除相关 DOM 内容的。

实现结果如下:

tutieshi_494x218_7s.gif

我们从上面的实现效果来看,似乎没什么问题。

我们在动态更新 DOM 内容的 effect 执行的副作用函数中添加一个打印日志来观察一下实现效果:

effect(() => {
    counterEl.textContent = count.value
+    console.log('动态变更 DOM 内容', count.value)
})

观察结果如下:

tutieshi_504x222_5s.gif

这时我们发现,即便我们已经删除了显示计算结果的 DOM,但重新点击 + 按钮的时候,effect 的副作用函数还是继续执行。如果我们有大量这样的功能的话,那么会对我们的内存性能带来影响,所以我们需要及时释放不需要的内存,在上述例子中就是当显示计算结果的 DOM 被删除后,那么对应的响应式副作用也需要被删除,在上述例子中就是 effect 中副作用函数需要被删除。如果从发布订阅模式的角度来看,就是对应的订阅者要被删除。

删除 effect 中的副作用函数这个功能我们已经在第五篇中已经实现了,现在我们实现起来就很简单了,代码如下:

- effect(() => {
-    counterEl.textContent = count.value
-    console.log('动态变更 DOM 内容', count.value)
- })
+ const runner = effect(() => {
+    counterEl.textContent = count.value
+    console.log('动态变更 DOM 内容', count.value)
+ })
delEl.addEventListener('click', () => {
+    runner.effect.stop()
    const parent = counterEl.parentNode
    parent.removeChild(counterEl)
})

我们再来看看修改后的执行效果:

tutieshi_510x166_4s.gif

这时我们发现在删除相关 DOM 的时候同时清除相关的副作用函数,即便对应的响应式数据发生变化,那些已经被删除的副作用函数就不再执行了,这样就达到优化内存,提高响应式框架程序性能的作用了。

如果上述功能是一个 Vue3 的应用的话,计算结果可以使用一个组件来实现,那么当清除计算结果的时候,可以看作卸载计算结果的组件,那么也就是说在卸载组件的时候需要清除对应组件的响应式副作用函数

Vue3 组件的响应式副作用的收集与清除

在 Vue3.15 的版本的源码中,也就是 effectScope 相关代码提交的前一个版本,我们可以看到 Vue3 组件的响应式副作用收集过程是如下的:

image.png

首先在组件初始化的时候,会通过实例化 ReactiveEffect 类创建一个副作用对象,并且赋值给组件实例 instance.effect 上。

组件卸载的时候:

image.png

我们可以看到组件卸载的时候,又会从组件实例对象上取 ReactiveEffect 类的实例对象,然后执行 stop 方法清除组件的响应式副作用。

上述通过 ReactiveEffect 类创建的副作用对象主要应用于组件的 render 函数的包装函数,是 Vue3 系统底层自动创建的。而一个组件的响应式副作用并不止组件的 render 函数的包装函数,还有用户通过 watch、watchEffect、computed API 手动创建的响应式副作用。

例如 watch API:

image.png

在 watch API 的实现中也是通过实例化 ReactiveEffect 类创建一个副作用对象,然后再通过 recordInstanceBoundEffect 函数保存起来。recordInstanceBoundEffect 函数实现如下:

image.png

recordInstanceBoundEffect 函数实现的实现很简单,就是将用户通过 watch、watchEffect、computed API 手动创建的 ReactiveEffect 类的实例对象存储到组件实例对象的 effects 属性上。这样在组件卸载的时候,就可以通过获取组件实例上 effects 属性的值进行执行达到取消相关响应式副作用的目的。相关实现如下:

image.png

这个就是 Vue3 组件的响应式副作用是如何收集与清除的实现原理。在 Vue3 源码底层已经自动帮我们实现了在 Vue 组件的 setup 中,初始化的时候响应式副作用将被收集并绑定到当前实例,在实例被卸载的时候,响应式副作用则会自动的被取消追踪了。注意上述的实现是 Vue3.15 中的实现。在 Vue3.2 以后就通过 effectScope 进行实现了,那么为什么要通过 effectScope 进行实现呢?

手动处理响应式副作用的弊端

经过上文我们知道响应式副作用失效之后需要及时把它们销毁掉,否则会存在内存泄漏和意外行为的风险。而在 Vue3 的底层已经自动帮我们实现了响应式副作用的处理,我们在平时写应用的时候无需担心。但我们如果想实现一些基于 Vue3 响应式库 @vue/reactivity 的公共库的时候,我们可能就需要手动处理响应式副作用了。

例如下面的代码例子:

const count1 = ref(0)
const count2 = ref(0)
// 用于存储副作用对象,以便后续可以停止它们
const effectStacks = []
// 观察响应式变量 count1 的变化情况
const effect1 = effect(() => {
    console.log(`effect1:${count1.value}`)
})
// 手动收集 effect1 的副作用
effectStacks.push(effect1)
// 观察响应式变量 count2 的变化情况
const effect2 = effect(() => {
    console.log(`effect2:${count2.value}`)
})
// 手动收集 effect2 的副作用
effectStacks.push(effect2)

// 通过设置定时器每一秒让 count1 和 count2 自动增加 1
const t = setInterval(() => {
    if (count1.value === 2) {
        // 如果等于 2,则遍历 effectStacks 数组,调用每个副作用对象的 stop 方法来停止它们。
        effectStacks.forEach(effect => effect.effect.stop())
        clearInterval(t)
    } else {
        count1.value++
        count2.value++
    }
}, 1000)

我们上述代码使用 ref 创建了两个响应式变量 count1 和 count2,初始值都为 0,然后通过 effect 函数定义了两个响应式副作用 effect1 和 effect2 用来分别观察响应式变量 count1 和 count2 的变化情况,并且将这两个响应式副作用对象手动收集到 effectStacks 数组中。然后使用 setInterval 设置了一个定时器,每隔 1 秒执行一次,在定时器的回调函数中检查 count1 的值是否等于 2,如果等于 2,则遍历 effectStacks 数组,调用每个副作用对象的 stop 方法来停止它们,否则递增 count1 和 count2 的值。

总的来说就是通过手动收集副作用对象,可以在特定条件下(如 count1 达到 2)停止这些副作用,从而控制程序的执行流程。

现在我们再增加两个响应式变量 count3 和 count4,再分别观察它们的变化情况。

// 省略...
+ const count3 = ref(0)
+ const count4 = ref(0)
// 省略...

+ // 观察响应式变量 count3 的变化情况
+ const effect3 = effect(() => {
+    console.log(`effect1:${count3.value}`)
+ })
+ // 手动收集 effect3 的副作用
+ effectStacks.push(effect3)
+ // 观察响应式变量 count4 的变化情况
+ const effect4 = effect(() => {
+     console.log(`effect2:${count4.value}`)
+ })
+ // 手动收集 effect4 的副作用
+ effectStacks.push(effect4)

// 通过设置定时器每一秒让 count1 和 count2 自动增加 1
const t = setInterval(() => {
    if (count1.value === 2) {
        // 如果等于 2,则遍历 effectStacks 数组,调用每个副作用对象的 stop 方法来停止它们。
        effectStacks.forEach(effect => effect.effect.stop())
        clearInterval(t)
    } else {
        count1.value++
        count2.value++
+        count3.value++
+        count4.value++
    }
}, 1000)

现在我们想实现当 count1 的值等于 2 的时候停止对 count3count4 的观察,也就是要停止 effect3effect4 的副作用。这时我们发现要实现这个比较麻烦,需要我们重新定义一个全局存储 effect3effect4 的副作用的变量。

+ const effectStacks2 = []

// 观察响应式变量 count3 的变化情况
const effect3 = effect(() => {
    console.log(`effect1:${count3.value}`)
})
// 手动收集 effect3 的副作用
- effectStacks.push(effect3)
+ effectStacks2.push(effect3)
// 观察响应式变量 count4 的变化情况
const effect4 = effect(() => {
    console.log(`effect2:${count4.value}`)
})
// 手动收集 effect4 的副作用
- effectStacks.push(effect4)
+ effectStacks2.push(effect4)

// 通过设置定时器每一秒让 count1 和 count2 自动增加 1
const t = setInterval(() => {
    if (count1.value === 2) {
        // 如果等于 2,则遍历 effectStacks2 数组,调用每个副作用对象的 stop 方法来停止对 `count3` 和 `count4` 的观察。
-        effectStacks.forEach(effect => effect.effect.stop())
+        effectStacks2.forEach(effect => effect.effect.stop())
        clearInterval(t)
    } else {
        count1.value++
        count2.value++
        count3.value++
        count4.value++
    }
}, 1000)

我们发现目前我们对响应式副作用的管理是非常麻烦的,怎么可以实现非常方便地管理响应式副作用呢?这时我们的 effectScope 就要登场了。

effectScope 的实现原理

我们在上一小节遇到的问题就是目前我们对响应式副作用的管理是非常的麻烦,我们希望可以很方便地把响应式副作用 effect1effect2 归一组,把 effect3effect4 归一组。其实在 Vue3 组件的响应式副作用的收集与清除 那小节中可以知道,每个组件的响应式副作用都自动收集到组件实例对象上了,所以在组件卸载的时候,也就很方便把相关的副作用也卸载了。那么有什么方案呢?

其实对发布订阅模式理解透彻的同学,可以很清楚地知道,我们在上一小节中实现的手动进行处理响应式副作用的方法,本质就是一个发布订阅模式的应用。

首先是创建一个订阅者存储中心的变量:

const effectStacks = []

然后所谓手动收集每个响应式副作用对象,其实是订阅的动作。

effectStacks.push(effect1)

最后在需要的时候,去通知每一个订阅者。

effectStacks.forEach(effect => effect.effect.stop())

这其实就是发布订阅模式的最核心的要义。

通过我们前面章节对发布订阅模式的学习,我们知道订阅者存储中心可以由一个叫消息代理中心类来实现,例如我们前面实现的 EventBus,通过 new EventBus() 我们就可以创建不同分组的事件总线,很明显这个模式同样适合我们上面的需求。那么如果你熟悉发布订阅模式的话,你可以很快写出我们现在需要实现的消息代理中心类 EffectScope 的基本框架代码。

那么根据我们前面实现 EventBus 类或者消息代理类的实现,我们可以得出以下代码:

class EffectScope {
    // 响应式副作用对象存储中心
    effects = []
    constructor() {

    }
    // 订阅,也就是收集响应式副作用对象
    sub() {

    }
    // 通知,也就是停止收集到的响应式副作用对象
    notify() {
        this.effects.forEach(e => e.stop())
    }
}

现在我们就可以通过以下方式创建不同的响应式副作用分组了。代码如下:

const scope = new EffectScope()

那么接下来就需要思考怎么去实现把响应式副作用对象收集到 EffectScope 类内部的 effects 属性上。在代码实现上我们可以参考 effect 函数的实现,代码如下:

const count1 = ref(0)
const count2 = ref(0)
scope.sub(() => {
    effect(() => {
        console.log(`effect1:${count1.value}`)
    })
    effect(() => {
        console.log(`effect2:${count2.value}`)
    })
})

就是给 sub 方法传递一个包装函数,那么在 EffectScope 类中的 sub 方法最终需要执行一下这个包装函数。

class EffectScope {
    // 省略...
    sub(fn) {
       fn()
    }
   // 省略...
}

通过前面对 Vue3 响应式原理的学习,我们知道所谓响应式副作用对象其实就是 ReactiveEffect 类的实例对象。那么也就是说在实例化 ReactiveEffect 类的时候就需要去把 ReactiveEffect 类的实例对象添加到 EffectScope 类的 effects 属性上。

首先我们需要创建一个记录当前激活的作用域对象的全局变量。代码如下:

+ // 记录当前激活的作用域对象
+ let activeEffectScope
class EffectScope {
    // 省略...
    sub(fn) {
+        activeEffectScope = this
        fn()
+        activeEffectScope = null
    }
   // 省略...
}

如果还记得 Vue 响应式原理的实现的同学,应该对上述代码的套路很熟悉,所以我们真的彻底理解底层的知识,那么学习其他相关的知识就能达到触类旁通的效果,这也是为什么有些人学习新知识学得那么快的原因。

接下来我们就可以在实例化 ReactiveEffect 类的时候就需要去把 ReactiveEffect 类的实例对象添加到全局变量 activeEffectScopeeffects 属性上即可。代码实现如下:

class ReactiveEffect {
    deps = []
    constructor(fn) {
        this._fn = fn
+        // 在定义副作用时,自动将它们关联到当前的作用域。
+        if (activeEffectScope) {
+            activeEffectScope.effects.push(this)
+        }
    }
    // 省略...
} 

这样我们就可以进行重新测试了,测试代码如下:

setInterval(() => {
    console.log('=====')
    if (count1.value === 2) {
        scope1.notify()
    }
    count1.value++
    count2.value++
}, 1000)

测试结果如下:

tutieshi_454x284_6s.gif

从测试结果可以看到,我们实现了通过作用域对响应式副作用对象的收集和卸载是成功的。

为了我们的代码更有语义,我们对上述代码进行迭代优化:

class ReactiveEffect {
    deps = []
    constructor(fn) {
        this._fn = fn
-        if (activeEffectScope) {
-            activeEffectScope.effects.push(this)
-        }
+        recordEffectScope(this)
    }
    // 省略...
} 

// 省略...

+ function recordEffectScope(effect) {
+     if (activeEffectScope) {
+         activeEffectScope.effects.push(effect)
+     }
+ }

封装一个在定义副作用时,自动将它们关联到当前的作用域的函数:recordEffectScope

同时修改 EffectScope 类中的相关方法的名称让它们更具有语义性。具体修改如下:

class EffectScope {
    // 省略...
-    sub() {
+    run(fn) {
    // 省略...
    }
    
-    notify() {
+    stop() {
        // 省略...
    }
}

+ // 创建作用域的工厂函数
+ function effectScope() {
+     return new EffectScope()
+ }

同时封装了一个创建作用域的工厂函数 effectScope

这时我们再实现我们之前的需求就很方便了。代码实现如下:

const count1 = ref(0)
const count2 = ref(0)
const count3 = ref(0)
const count4 = ref(0)
// 作用域1
const scope1 = effectScope()
scope1.run(() => {
    effect(() => {
        console.log(`effect1:${count1.value}`)
    })

    effect(() => {
        console.log(`effect2:${count2.value}`)
    })
})
// 作用域2
const scope2 = effectScope()
scope2.run(() => {
    effect(() => {
        console.log(`effect3:${count4.value}`)
    })

    effect(() => {
        console.log(`effect4:${count4.value}`)
    })
})

setInterval(() => {
    console.log('=====')
    if (count1.value === 1) {
        // 当 count1 等于 1 时停止作用域2的依赖追踪
        scope2.stop()
    }
    count1.value++
    count2.value++
    count3.value++
    count4.value++
}, 1000)

测试结果如下:

tutieshi_460x444_4s.gif

自此我们就实现了 effectScope 的最核心的功能,本质上就是一个发布订阅模式的应用,effectScope 函数是一个工厂函数,通过实例化 EffectScope 类,创建不同的作用域对象,而 EffectScope 类本质上是发布订阅模式中的消息代理类或者我们经常说的事件总线类,然后通过 run 方法运行一个包装函数,本质上是在订阅响应式副作用对象,最后可以通过 stop 方法通知每个订阅的响应式副作用对象进行停止追踪响应式依赖。所以如果你对发布订阅模式非常熟悉,那么你对 effectScope 的实现原理也非常容易理解了。

嵌套作用域

我们目前想实现这样的功能,在一个作用域里面嵌套一个作用域,代码如下:

const count1 = ref(0)
const count2 = ref(0)
const count3 = ref(0)
const count4 = ref(0)
const scope1 = effectScope()
// 作用域1
scope1.run(() => {
    effect(() => {
        console.log(`effect1:${count1.value}`)
    })

    effect(() => {
        console.log(`effect2:${count2.value}`)
    })
    // 嵌套作用域
    const scope2 = effectScope()
    scope2.run(() => {
        effect(() => {
            console.log(`effect3:${count4.value}`)
        })

        effect(() => {
            console.log(`effect4:${count4.value}`)
        })
    })
})

setInterval(() => {
    console.log('=====')
    if (count1.value === 1) {
        // 停止外层作用域的依赖追踪
        scope1.stop()
    }
    count1.value++
    count2.value++
    count3.value++
    count4.value++
}, 1000)

我们想当停止外层作用域的依赖追踪后,嵌套的作用域中的依赖也停止追踪。目前测试结果如下:

tutieshi_444x392_4s.gif

我们发现当我们停止了外层作用域的依赖追踪后,嵌套的作用域中的依赖还是能够进行追踪的,这是因为我们目前是已经实现了作用域隔离,也就是不同作用域中的依赖是互不干扰的,但有些场景可能我们又需要嵌套作用域是能够关联的,也就是停止了外层作用域,嵌套的作用域也应该停止。

要实现这个功能,其实也很简单,还是通过发布订阅模式的应用去实现,从上文可以知道,effectScope 的实现原理本质就是发布订阅模式的应用,EffectScope 类就是消息代理中心,所谓订阅者就是 ReactiveEffect 类的实例对象。从在们前面所学的知识可以知道,订阅者也可以是发布者,发布者也可以是订阅者,或者说观察者也可以是被观察者,被观察者也可以是观察者。

所以根据这个规则,我们可以让父级的 EffectScope 订阅嵌套的 EffectScope。代码实现如下:

class EffectScope {
    effects = []
    constructor() {
        // 订阅嵌套的 EffectScope
+        recordEffectScope(this)
    }
    // 省略...
}

而 EffectScope 类上有个 stop 方法,而 ReactiveEffect 类上也有一个 stop 方法,所以在执行父级作用域的 stop 方法循环 effects 属性上的订阅者的时候,有可能是嵌套的作用域,而因为都共同拥有一个 stop 方法,所以在执行嵌套作用域的实例对象的 stop 方法的时候又会去循环嵌套作用域中 effets 属性中订阅者,这样就实现了父作用域与嵌套作用域的依赖的共同管理了。

这时我们再来测试一下上述的嵌套作用域的测试代码。测试结果如下:

tutieshi_444x324_4s.gif

这时我们发现清除父级作用域的时候,嵌套作用域的响应式副作用也被清除了。

我们还需要继续迭代一下我们的功能,现在是默认就关联收集了嵌套作用域了,这样就失去了隔离作用域的作用了。那么我们希望做一个开关,开关开启的时候就进行作用域隔离,默认就收集嵌套作用域的响应式副作用。

实现代码如下:

class EffectScope {
    // 省略...
-    constructor() {
+    constructor(detached = false) {
+        if (!detached) {
            recordEffectScope(this)
+        }
    }
    // 省略...
}

// 创建作用域的工厂函数
- function effectScope() {
+ function effectScope(detached) {
-    return new EffectScope()
+    return new EffectScope(detached)
}

这样我们就初步实现了 effectScope 功能了。

在 Vue3 底层应用 effectScope

在 Vue3.2 以后 Vue3 组件的响应式副作用的收集与清除的实现就通过 effectScope 进行了。通过上文我们知道一个组件的响应式副作用是有两种类型的,分别是由组件的 render 函数的包装函数和用户通过 watch、watchEffect、computed API 手动创建的响应式副作用。在 Vue3.2 以前,它们分别收集在组件实例的 effect 和 effects 两个属性上。在 Vue3.2 以后实现就通过 effectScope 进行实现了,就只需需要一个 scope 属性来存储 EffectScope 实例对象即可。

image.png

从上图我们可以看到在 Vue3.2 以后组件实例化后,也会在组件实例对象的 scope 属性实例化一个 EffectScope 实例对象。

然后我们知道一个组件的响应式变量是在 setup 方法中创建的,然后在 render 方法中使用,当响应式变量发生变化的时候,render 函数重新执行,而要实现这个功能是通过 ReactiveEffect 来实现的。

image.png

然后通过上文对 effectScope 的实现原理的讲解我们知道,在实例化 ReactiveEffect 的时候,会把 ReactiveEffect 实例对象收集到 EffectScope 的实例对象的 effects 属性上。然后在组件卸载的时候,就可以通过组件实例对象上的 scope 属性的 stop 方法进行卸载相关的副作用了。

image.png

隔离副作用的实际应用

我们使用 Vue3 Composition API 编写一个自定义钩子(hook)函数,名为 useCounter。它的功能是实现一个简单的计数器,并附带了一个额外的特性:当计数器的值是偶数时,计算并存储这个值的两倍。

以下是 useCounter 的代码实现:

import { ref, watch } from "vue"

export function useCounter() {
    // 定义计数器
    const counter = ref(0)
    // 增加
    const increment = () => counter.value++
    // 减少
    const decrement = () => counter.value--
    // 计数器的偶数双倍值
    const doubleCount = ref(0)
    // 监听计数器值的变化
    watch(() => counter.value, (newVal) => {
        // 当计数器的值是偶数时,计算并存储这个值的两倍
        if (newVal % 2 === 0) {
            doubleCount.value = newVal * 2 
        }
    })

    return {
      counter,
      doubleCount,
      increment,
      decrement
    }
}

接着我们在两个组件中使用它。

Counter1.vue

<template>
    <div>
        <p>当前值: {{state.counter}}</p>
        <p>偶数双倍值:{{state.doubleCount}}</p>
        <button @click="state.increment">+</button>
        <button @click="state.decrement">-</button>
    </div>
</template>
<script setup>
import { useCounter } from '../hooks/useCounter';
const state = useCounter()
</script>

Counter2.vue

<template>
    <div>
        <p>当前值: {{state.counter}}</p>
        <p>偶数双倍值:{{state.doubleCount}}</p>
        <button @click="state.increment">+</button>
        <button @click="state.decrement">-</button>
    </div>
</template>
<script setup>
import { useCounter } from '../hooks/useCounter';
const state = useCounter()
</script>

接着在 App.vue 中引用它们。

App.vue

<script setup>
import Counter1 from './components/Counter1.vue'
import Counter2 from './components/Counter2.vue'
</script>

<template>
  <Counter1 />
  <Counter2 />
</template>

实现效果如下:

tutieshi_442x432_12s.gif

我们当前的实现是两个组件的状态是不共享的,分别各自计算各自的值,现在我们希望它们是互相共享状态的,也就是点击 组件1 中的按钮进行计算的时候,组件2 中的状态也是同时改变的,同样地点击组件2中的按钮进行计算的时候,组件1中的状态也是同时改变的。

通常要在多个组件之间共享数据状态,我们一般在最上层的父组件创建响应式变量,然后通过层层传递进行使用,这种很明显层级过多时候很不方便;或者使用 Vuex 或者 Pinia,但一般在小型项目中,比如我们上述的计数器功能,如果我们也引用这种第三方库,代码就显得很臃肿了。所以我们可以自己实现一个小型的状态管理工具函数。

那么我们要实现在多个组件共享数据状态,本质是要创建一个单例的数据状态变量,也就是单例模式的应用。

单例模式是一种设计模式,目的是确保一个类或者对象在整个应用生命周期中只被实例化一次,并提供全局访问点。

在 JavaScript 中,单例模式通常通过闭包来实现,利用闭包保存一个私有的实例变量,同时通过一个函数来控制创建和访问这个实例。

具体代码实现如下:

function createGlobalState(stateFactory) {
    let initialized = false;
    let state;
    return ((...args) => {
      if (!initialized) {
        state = stateFactory(...args);
        initialized = true;
      }
      return state;
    });
}

上面的 JavaScript 代码通过闭包和函数表达式实现了一个简单的单例模式,确保某个状态(state)对象只会被创建一次,并始终返回同一个实例。

createGlobalState 是一个工厂函数,它接受一个参数 stateFactory,这个参数也是一个工厂函数,负责生成状态对象。也就是说,我们把状态对象的创建逻辑封装在 stateFactory 中。对于我们上面的计算器的实现例子,那么这个参数就是 useCounter 函数。使用例子如下:

export const useCounterState = createGlobalState(useCounter)

createGlobalState 返回的是一个匿名函数(箭头函数),从上述例子可以知道变量 useCounterState 就是一个函数,这个函数会被用来获取状态对象。

在 createGlobalState 函数内部,声明了两个私有变量:initialized 标记状态对象是否已经被初始化(默认值是 false), state 变量存储状态对象的引用。只有当 initialized 是 false 时,才会调用 stateFactory 创建状态对象,并将其赋值给 state。同时将 initialized 设置为 true,表示状态对象已经被创建。这样每次调用匿名函数时,都会返回同一个 state 对象,从而实现单例模式的效果。

接下来我们在两个组件 Counter1.vue 和 Counter2.vue 中进行以下引用:

import { useCounterState } from '../hooks/useCounter';
const state = useCounterState();

然后测试结果如下:

tutieshi_420x408_8s.gif

这时,我们可以看到两个组件的状态实现了互相共享,也就是点击 组件1 中的按钮进行计算的时候,组件2 中的状态也是同时改变的,同样地点击组件2中的按钮进行计算的时候,组件1中的状态也是同时改变的。

至此我们好像还没讲到实现副作用隔离的作用是什么。接下来我们再实现一个小功能,代码如下:

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

+ const isShow = ref(true)
+ const handleHide = () => {
+   isShow.value = false
+ }
</script>

<template>
-  <Counter1 />
+  <Counter1 v-if="isShow" />
  <Counter2 />
+  <button @click="handleHide">隐藏第一个组件</button>
</template>

实现效果如下:

tutieshi_392x384_10s.gif

我们可以看到当我们隐藏第一个组件之后,第二个组件的偶数双倍值失效了。这是为什么呢?首先是因为偶数双倍值的实现是通过 watch 来实现的,从而产生了一个副作用,并且因为第一个组件是最新执行的,所以这个副作用就被收集到了第一个组件的实例对象上,而又因为我们是通过单例模式实现了状态共享,所以第二个组件使用的状态变量实际上跟第一个组件使用的状态变量是同一个,所以第一个组件使用 watch 产生的副作用被隐藏从而删除之后,第二个组件的相关功能也就失效了。

所以这个时候,我们就要想办法,让这些第三方的库产生的副作用不要和组件进行绑定,而是要和组件进行隔离,这个时候很明显就需要用到 effectScope 功能了,也是 effectScope 功能的最大作用之一。所以我们对 createGlobalState 函数进行修改,具体修改如下:

function createGlobalState(stateFactory) {
    let initialized = false;
    let state;
+    const scope = effectScope(true)
    return ((...args) => {
      if (!initialized) {
-        state = stateFactory(...args);
+        state = scope.run(() => stateFactory(...args));
        initialized = true;
      }
      return state;
    });
}

通过上文我们知道 effectScope 函数传参为 true 时就会进行作用域隔离。

这时我们再进行测试:

tutieshi_274x374_9s.gif

这时我们发现当我们隐藏第一个组件的时候,第二个组件的偶数双倍值功能不再受影响了。

至此 Vue3 中新增的 effectScope API 功能的实现原理和相关作用我们都介绍得差不多了。

总结

effectScope 是 Vue 3.2 提供的高阶响应式副作用管理工具,其核心本质是发布订阅模式的应用。通过 EffectScope 类作为消息代理中心,run 方法负责收集当前作用域内的所有 ReactiveEffect 实例(即副作用),stop 方法则批量停止它们。它还支持嵌套作用域,通过 detached 参数控制父子作用域是否关联,实现了灵活的副作用隔离。

在 Vue 3.2 之后,组件内部使用 effectScope 统一管理渲染副作用和用户定义的 watch/computed 副作用,替代了之前分散在 instance.effect 和 effects 数组的手动管理方式,简化了代码并提升了内存安全。此外,在开发可复用的组合式函数(如 createGlobalState 实现全局状态共享)时,利用隔离的 effectScope 可以避免副作用被错误绑定到特定组件上,从而保证状态跨组件共享时的正确性。掌握 effectScope 有助于深入理解 Vue 3 响应式系统及构建更健壮的公共库。

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

Vue3 + Three.js 仓储数字孪生:按需渲染架构与五大核心功能复盘

🛠️ Vue3 + Three.js 仓储数字孪生:按需渲染架构与五大核心功能复盘

在重构企业级 3D 仓储数字孪生项目的过程中,我摒弃了原项目过度封装的插件机制,转而采用 Vue 3 Composition API 结合 Three.js 原生 API 进行开发。

为了追求极致的性能,我在这次重构中彻底去掉了传统的 requestAnimationFrame 死循环,采用了针对静态场景性能最佳的**“按需渲染(On-Demand Rendering)”**架构。本文将详细复盘我实现的五大核心功能,并给出对应的核心脱水代码。

💡 功能一:搭建纯粹的 3D 舞台与光影配置

需求目标:在浏览器中初始化 3D 画布,并引入相机、环境光与平行光,为后续的模型加载提供基础环境。

实现思路: 在 Vue 的 onMounted 钩子中,构建 Three.js 的核心对象。由于我们放弃了动画循环,在场景初始化完毕后,必须手动调用一次 renderer.render() 来“按下快门”拍下第一张照片。

核心代码

import * as THREE from 'three';

// 1. 场景与光影
const scene = new THREE.Scene();
scene.background = new THREE.Color('#2b2b2b');

// 添加环境光(提亮全局)与平行光(制造立体感)
scene.add(new THREE.AmbientLight(0xffffff, 0.8));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
dirLight.position.set(10, 20, 10);
scene.add(dirLight);

// 2. 相机配置
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
camera.position.set(0, 15, 25);

// 3. WebGL 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
containerRef.value.appendChild(renderer.domElement);

// 初始化完成后,手动渲染第一帧(极其重要,否则黑屏)
renderer.render(scene, camera);

🏭 功能二:工业级 GLB 模型的解析与加载

需求目标:将大厂工业级的 .glb 仓储模型加载到场景中展示,并解决 Meshopt 压缩网格的解析报错。

实现思路: 数字孪生的高精度模型往往使用 Meshoptimizer 进行压缩,以极大地减小网络传输体积。在实例化 GLTFLoader 后,必须强行注入 MeshoptDecoder。模型异步加载完成后,因为没有动画循环自动重绘,必须在回调函数里手动触发一次渲染

核心代码

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';

const gltfLoader = new GLTFLoader();
// 核心:解决工业级模型的网格压缩报错
gltfLoader.setMeshoptDecoder(MeshoptDecoder); 

gltfLoader.load('/warehouse.glb', (gltf) => {
  scene.add(gltf.scene);
  
  // 模型加载并添加到场景后,必须手动刷新画面才能看到
  renderer.render(scene, camera);
  console.log('模型加载成功并已渲染!');
});

🎯 功能三:射线拾取、克隆高亮与信息标签

需求目标:鼠标点击 3D 屏幕,选中特定货架使其变红高亮,并在货架正上方弹出 HTML 信息标签。

实现思路: 利用 Raycaster 将鼠标二维坐标转换为 3D 射线检测碰撞。

  • 高亮去重:使用 material.clone() 剥离共享材质,防止“牵一发而动全身”。
  • 标签定位:使用 CSS2DRenderer 配合 THREE.Box3 计算货架的世界绝对最高点,规避模型复杂的内部层级带来的局部坐标偏移。
  • 按需更新:高亮和弹窗发生后,手动调用两者的 render 方法刷新画面。

核心代码

import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';

// ... 提前初始化 labelRenderer 并挂载到 DOM ...
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

const onMouseClick = (event) => {
  // 坐标转换与射线检测
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(scene.children, true);

  if (intersects.length > 0) {
    const obj = intersects[0].object;
    
    // 1. 材质克隆与独立高亮
    obj.material = obj.material.clone(); 
    obj.material.color.set('#ff0000');   

    // 2. CSS2D 标签绝对坐标计算
    const div = document.createElement('div');
    div.textContent = `📍 ${obj.name}`;
    div.className = 'three-label'; 
    const label = new CSS2DObject(div);

    const box = new THREE.Box3().setFromObject(obj);
    const center = new THREE.Vector3();
    box.getCenter(center);
    label.position.set(center.x, box.max.y + 0.5, center.z);
    scene.add(label);

    // 3. 核心:状态改变后,手动更新 WebGL 和 CSS2D 画面
    renderer.render(scene, camera);
    labelRenderer.render(scene, camera);
  }
};

🕹️ 功能四:基于 Change 事件的视角操作(缩放与旋转)

需求目标:使用鼠标拖拽旋转、滚轮缩放查看模型细节,同时摒弃耗费 GPU 的全局动画循环

实现思路: 引入 OrbitControls 接管相机的操作。重要避坑:为了实现“按需渲染”,必须关闭控制器的阻尼效果(enableDamping = false),否则没有动画循环为其计算数学递减,拖拽会严重卡顿。随后,我们将画面刷新逻辑直接绑定在控制器的 change 事件上。

核心代码

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

const controls = new OrbitControls(camera, renderer.domElement);

// ⚠️ 大厂优化铁律:采用按需渲染时,必须关闭阻尼惯性
controls.enableDamping = false; 

// 监听用户的鼠标/触摸操作
controls.addEventListener('change', () => {
  // 只有当相机视角发生真实变化时,才“按需”冲洗照片
  renderer.render(scene, camera);
  
  if (labelRenderer) {
    labelRenderer.render(scene, camera);
  }
});

💥 功能五:包围盒控制(相机空气墙)防穿模

需求目标:防止用户在缩放或平移视角时,不小心把相机钻到地板下面,或者穿出仓库大楼外部导致画面穿帮。

实现思路: 不使用庞大的物理引擎,利用原生 THREE.Box3 定义一个安全的可视空间(空气墙)。借助上一步实现的 change 按需渲染机制,在每次视角变化准备渲染之前,使用 Vector3.clamp() 方法强制将相机的坐标钳制在安全盒子内部。

核心代码

// 1. 划定一个仓库的安全边界(空气墙 Box3)
// 假设仓库范围是 X(-50~50), Y(1~30), Z(-50~50)
const safeBounds = new THREE.Box3(
  new THREE.Vector3(-50, 1, -50), // Y轴最小为1,防止钻入地板
  new THREE.Vector3(50, 30, 50)   // 限制最大高度和边界
);

controls.addEventListener('change', () => {
  // 2. 碰撞钳制逻辑:一旦相机试图越出边界,强制将其拉回安全区域边缘
  camera.position.clamp(safeBounds.min, safeBounds.max);
  
  // 3. 渲染钳制后的合法画面
  renderer.render(scene, camera);
  if (labelRenderer) labelRenderer.render(scene, camera);
});

🚀 总结与架构沉淀

通过这次重构,我不仅掌握了从模型加载、射线拾取到碰撞检测的完整 3D 链路,更深刻体会到了**“按需渲染(On-Demand Rendering)”**在前端工程中的威力。

去除了全局的 requestAnimationFrame 后,当用户不操作页面时,GPU 占用率直接降为 0%。结合 Vue 3 优秀的响应式系统,这种极致轻量、彻底解耦的代码架构,才是现代化 Web 3D 项目应该追求的形态。

❌