阅读视图

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

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-js 2025详细解读

往期文章:

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-js 2021 & state-of-css 2021详细解读

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-css 2022 & state-of-js 2022详细解读

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-css 2023详细解读

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-js 2023详细解读

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-css 2024和state-of-js 2024详细解读

一、写在前面

  • 本次分享的数据来源是state-of-js,是由Devgraphics开源社区团队发起的前端生态圈中规模最大的数据调查。
  • 想要贡献state-of-js调查结果中文翻译的同学可以联系我,或者直接向Devographics/locale-zh-Hans这个仓库提PR,然后艾特我来帮你review。
  • 如果这篇文章有其他意见或更好的建议,欢迎各位同学们多多指教。

二、受访者统计

今年的state-of-js调查共回收了13002份问卷结果。和去年相问卷结果又少了一些。

其实自从2022年起,填写问卷的人就越来越少,原因无外乎这么几个:

  • 前端的整体热度都在走低,像是google trends上前端相关的搜索词的热度都在下降;
  • 问卷内容过长导致内容填写起来比较麻烦;
  • 受访者虽然一直关注这项调查,但填了第一年的问卷之后第二年的问卷就不填了等等。

而在今年我结合我在Datawhale做的一些数据调查来看,有一个更重要的原因,就是AI的崛起——大部分开发者们的注意力已经转向了AI领域(包括我自己也是),基本不会在前端领域投入过多关注了

之前我也和调查发起人@SachaG聊过state-of-js调查的未来,作为一项坚持了9年的前端数据调查,也算是见证了前端领域的崛起与衰落。而如今,前端领域的热度早已不再是当年的样子,这项调查也不知道还能做多少年,大家且看且珍惜吧。

三、JS特性

语法特性

从今年的语法特性使用情况来看,社区对提升代码健壮性和简洁性的新特性抱有极大的热情:

  • 空值合并 运算符 ?? 的使用率高达 87% ,已经成为事实上的标准,这说明开发者在处理 nullundefined 时,迫切需要一种比 || 更严谨、更明确的工具来避免将 0false 等有效值意外覆盖,在日常开发中,我们应当优先使用 ?? 来处理默认值赋值,以增强代码的稳定性和可预测性。
  • 动态导入( Dynamic Import 66% 的使用率紧随其后,反映出代码分割和按需加载已是现代 Web 应用性能优化的核心实践,在构建大型应用、特别是需要考虑首屏加载速度的场景时,动态导入几乎是必修课。
  • 类私有字段( Private Fields 逻辑赋值 运算符 Logical Assignment 的使用率分别为 43%35% ,表明封装和代码简写同样是开发者追求的目标,尤其是私有字段,为在团队协作中保护内部状态、减少意外修改提供了语言层面的保障。

Array、Set、Object的特性

今年对 ArraySetObject 数据结构的新特性调查,揭示了不可变性(Immutability)数据处理便利性 已成为前端开发的核心趋势:

  • 返回新数组的 toSorted() 使用率已达 47% ,其孪生兄弟 toReversed() 也达到 37% ,说明社区正主动避免原地修改数组带来的副作用。
  • Set 新方法整体普及度不高,但在使用者中 union()intersection()difference() 等集合运算需求最集中,开始用于表达更复杂的数据关系与权限逻辑。
  • 首次进入调查的 Object.groupBy() 拿到 39% 使用率,说明了“按字段分组”这类高频需求可以摆脱 Lodash 等库,直接靠原生 JS 优雅解决。

Promise的特性

在异步编程领域,对多个 Promise 的精细化控制能力已成为现代前端的标配:

  • Promise.allSettled()52% 的使用率登顶,适合在“批量请求但不希望单点失败拖垮整体流程”的场景下使用,例如并行拉取多个非关键数据源、日志或埋点结果,它能保证我们总能拿到每个 Promise 的最终状态。
  • Promise.any() 使用率也达到 47% ,是“抢最快一个结果”的利器,典型场景是对多个镜像服务发起并行请求、谁先返回就用谁,从而显著优化响应延迟。
  • 这两个 API 的走红说明前端异步模型已经从“能并发”走向“可编排”,开发者不再满足于简单的 Promise.all,而是开始为不同业务场景选择更合适的并发策略。

浏览器API

浏览器 API 的使用情况反映了 Web 应用能力正从传统的页面展示,向功能更丰富、更接近原生应用的形态演进:

  • WebSocket 仍以 64% 的使用率牢牢占据基础设施地位,支撑了社交、协作、监控看板等场景中的实时通信。
  • PWA 使用率达到 48% ,说明离线能力、安装体验和通知能力已经被越来越多团队纳入评估维度。
  • 更值得关注的是 WebAssembly (WASM) ,使用率已达 21% 且排名上升 2 位,高性能语言(如 C++、Rust)编译到浏览器侧解决音视频处理、加解密、游戏等计算密集型问题,正在从先锋实践迈向工程常规武器。

JS语言的痛点

关于 JS 语言自身的痛点,今年的结果再次印证了社区共识:

  • 缺乏静态类型(Static Typing)28% 的提及率高居第一,这直接解释了为何 TypeScript 能在短时间内成为事实标准——大型项目在可维护性、重构安全和错误提前暴露上的诉求远非动态类型所能满足。
  • 日期处理(Dates)10% 排名第二,说明即便有 Temporal 提案在推进,现实中开发者仍大量依赖 date-fnsDay.js 等第三方库来填补标准库短板。
  • 同时,ESM CJS 的兼容问题标准库整体匮乏 等历史包袱也依然是工程实践中的绊脚石,这些痛点共同构成了“JS 好用但不够省心”的真实写照。

浏览器的痛点

当我们把视线从语言本身转向其运行环境——浏览器时,痛点显得更具工程现实感:

  • 跨浏览器支持(Browser support)31% 的提及率稳居首位,说明即便现代浏览器在标准实现上趋于一致,边缘行为差异、新特性落地节奏和兼容性策略仍是困扰前端团队的主要问题。
  • 浏览器测试(Browser testing)13% 位列第二,本质上是跨浏览器差异在测试和回归成本上的放大反馈
  • 而被单独点名的 Safari7% 成为第三大痛点,很多团队已经默认把它视作“新时代的 IE”,其标准跟进节奏和独特限制,为跨端一致性和平滑体验带来了额外负担。

四、JS技术

综述

这两张图分别从“历史趋势”和“当前满意度”两个维度,为我们描绘了 JS 技术生态的全景图:

  • 左侧四象限清晰展示出以 Vite 为代表的新一代工具,正沿着“低使用、高满意度”向“高使用、高满意度”高速跃迁,而曾经的王者 webpack 虽然仍有庞大使用量,但满意度明显滑落且轨迹线转为紫色,显示出疲态
  • 从右侧满意度分级我们可以发现,Vite (98%)Vitest (97%)Playwright (94%)Astro (94%) 等新星占据 S 级,而 webpack (26%)Angular (48%)Next.js (55%) 等传统选手则跌入 B/C 级,这意味着“存量巨大但口碑一般”的技术栈随时可能迎来用户流失;同时,Vite 生态中 Vite + Vitest 的双双登顶也说明高度协同的一体化工具链的优势,对于开发者而言,技术选型时不能只看当前占有率,更要关注满意度和趋势曲线,尤其要多留意那些位于右下象限、线条仍在上扬的新工具。

前端框架

前端框架的长期“三巨头”格局正在被悄然改写:

  • React 依旧以 80%+ 的使用率牢牢占据生态核心,但满意度已滑落到 B 级(72%),复杂的心智模型和渐进式演化成本让不少团队收到困扰。
  • Vue.js 在 2022 年前后正式超越 Angular 成为第二大框架,并以 84% 的满意度稳居 A 级,证明其在开发体验与性能之间找到了不错的平衡点。
  • Svelte 则凭借“无虚拟 DOM”的编译时理念持续走高,使用率已升至 26% ,成为追求极致性能和简洁语法团队的心头好。
  • 更有意思的是 HTMX,在近两年实现爆发式增长、使用率来到 13% ,它用“回归 HTML、用属性驱动交互”的思路,对当下 JS-heavy 的前端栈提出了有力反思。

元框架(前后端一体化框架)

元框架领域呈现出“一家独大 + 新星涌现”的混合格局:

  • Next.js 继续凭借与 React 的深度绑定,以近 60% 的使用率统治榜单,是大多数 React 团队构建生产级应用的默认选项,App Router 等激进改动和整体复杂度的提升正在透支开发者耐心。
  • Nuxt 在 Vue 生态中稳扎稳打,使用率升至 28%
  • AstroSvelteKit 则是近年最值得关注的两颗新星,前者在内容密集型站点中大放异彩,后者与 Svelte 深度绑定,为全栈应用提供了端到端的极致体验。

后端框架

在 Node.js 后端框架领域,我们不难看出,还是有些新面孔:

  • 老牌选手 Express 仍以 80%+ 的使用率稳居第一,作为“薄核心 + 丰富中间件”的事实标准难以被完全替代,但 81% 的满意度也表明开发者正在寻找更现代的方案
  • tRPC 是过去两年最耀眼的新星,通过直接在 TypeScript 中实现端到端类型安全调用,大幅简化了前后端联调与接口演进的成本。

测试框架

JavaScript 测试生态正在经历一场“现代化重构”:

  • 在单元与集成测试层面,Jest75% 的使用率独占鳌头。
  • 端到端测试领域则被 Cypress (55%)Playwright (49%) 两强主导,其中 Playwright 以 94% 的满意度跻身 S 级,体现了其在稳定性、调试体验和多浏览器支持上的优势。
  • 紧随其后的是 Vitest,作为 Vite 生态的一员,在短短两年内使用率冲到 50% ,满意度更是高达 97% ,验证了“测试工具与构建工具深度一体化”带来的体验红利。

构建工具

前端构建工具领域也在发生变革:

  • webpack 依旧以 85% 的使用率占据绝对存量,但满意度已经跌至 26% ,复杂配置和缓慢构建让它越来越像一座难以完全迁移的“基础设施债务”。
  • Vite 则是新时代的领跑者,使用率在短短数年间拉升到 83% 、几乎追平 webpack,满意度更是高达 98% ,依托基于 Go 的 esbuild 实现极快冷启动和热更新,重新定义了“本地开发体验”的下限
  • 在更底层 esbuild 的直接使用率已达 52%SWC 也拿到 83% 的满意度,说明社区正将编译热点下沉到 Rust/Go 等原生实现,再在其之上搭建更友好的工具。

五、其它工具

JS库使用情况

在通用 JS 库层面,数据清晰地表明开发者最在乎两件事:

  • 类型安全数据处理效率。以 TypeScript 为优先设计的校验库 Zod48% 的使用率登顶,成为“运行时数据校验 + 类型推导”领域的绝对主角,反映出大家在 API 返回、表单输入等链路上,对类型与数据一致性的强烈诉求。
  • 传统工具库 Lodash (39%) 依然宝刀不老,仍在大量项目中承担通用数据处理职责。
  • 而在日期处理上,date-fns (39%)Moment (25%)Day.js (24%) 等多家共存,本质上是对 JS 原生日期能力长期缺位的弥补
  • 即便是已经被视作“老古董”的 jQuery (16%) ,也仍凭借海量遗留项目保持着不可忽视的存在感。

AI使用情况

AI 工具已经深度嵌入前端开发者的日常工作流,成为新的基础设施:

  • ChatGPT60% 的使用率位居首位,承担了问答、代码草稿生成、调试思路辅助等“外脑”角色。
  • 深度集成 IDE 的 GitHub Copilot 使用率也达 51% ,更偏向于在写代码时提供上下文感知补全与重构建议,两者形成“离线思考 + 在线自动补全”的互补关系
  • 与此同时,Claude (44%)Google Gemini (28%) 等通用大模型产品也在快速补位,说明开发者愿意多源头对比体验
  • 值得注意的是 AI-native 编辑器 Cursor 已有 26% 的使用率,一部分人开始直接迁移到“以 AI 为核心交互对象”的编辑环境中,这预示着未来开发工具形态本身也会被 AI 重塑。
  • 另外,国产大模型 Deepseek 也榜上有名,占据了 8% 的使用率。

其它编程语言使用情况

这张图展示了 JS 开发者的多语言画像:

  • Python41% 的占比成为最常见的第二语言,依托后端开发、自动化脚本、数据分析与 AI 等丰富场景,为前端同学打开了更多技术边界。
  • PHP (27%) 的存在感说明不少人仍在使用 Web 传统栈构建项目或是在维护古老的历史项目。
  • 在工具链和 DevOps 侧,Bash (22%) 几乎是所有工程师的“必修课”。
  • Java (21%)Go (20%)C# (19%) 等企业级后端语言,以及以安全与性能著称的 Rust (16%) ,则构成了很多前端开发者向全栈或更底层系统方向延展的技能支点。

六、使用情况及痛点问题

TS与JS的使用情况

这张分布图有力地说明,TypeScript 已经从“可选增强”进化为 JavaScript 生态的默认选项

  • 48% 的受访者表示项目代码 100% 使用 TS 编写,体现出“一旦采用就倾向于全量迁移”的强烈偏好;在所有项目(包括纯 JS、纯 TS 与混合工程)中计算得到的平均采用率高达 77% ,意味着当今前端代码大部分都运行在类型系统保护之下;仍坚持纯 JS 的开发者仅占 6% ,多半集中在遗留项目或极轻量脚本场景;对于在做技术选型的新项目来说,这几乎已经构成了一个共识结论:默认使用 TS,而不是再纠结要不要上 TS

AI代码生成情况

这张图刻画了 AI 在代码生成中的“真实渗透率”,结论很清晰:

  • AI 目前更像是开发者的“副驾驶”,而非自动写代码的主力工程师。只有 10% 的受访者认为项目代码完全没有 AI 贡献,说明九成以上的团队或多或少已经在用 AI 提效;最集中的区间是 1%–20% 代码由 AI 生成(占 38% ),典型用法是让 AI 帮忙写模板代码、样板逻辑、特定算法实现或提供重构建议,而不是让它从零实现完整模块;总体算下来,平均约有 29% 的代码可以归功于 AI,这是一个不容忽视但远未到“全自动开发”的比例,也意味着复杂业务建模、架构设计和质量把控这些高阶工作,短期内仍牢牢掌握在人类开发者手中。

JS的痛点问题

在所有 JS 开发痛点中,真正让团队头疼的并不是某个语法细节,而是宏观层面的工程复杂度:

  • 代码架构(Code Architecture)38% 的提及率高居榜首,说明随着前端项目体量和生命周期不断拉长,如何拆分模块、划分边界、治理依赖、避免“屎山”成为最大挑战。
  • 紧随其后的是 状态管理(State Management,34%) ,无论是 React 的 hooks 与各种状态库,还是 Vue 的 Pinia,跨组件、跨页面的复杂状态流转依然极易失控。
  • 依赖管理(Managing Dependencies,32%) 也是老大难问题,node_modules 黑洞、版本冲突、安全漏洞以及 ESM/CJS 兼容性都会侵蚀工程稳定性。
  • 相对而言,曾经广受诟病的 异步 代码(Async Code) 如今只剩 11% 的人视其为痛点,Promiseasync/await 已经在很大程度上平滑了这块心智负担,这也从侧面证明语言与工具的演进确实可以逐步“消灭”一部分历史问题。

七、总结

首先,毫无疑问,TypeScript 已然胜出。它赢下的不只是「能编译成js的工具」的争论,而是语言本身。Deno 和 Bun 早已原生支持它。如今,你甚至能在稳定版 Node.js 中直接编写 TypeScript了。

而 Vite 的时代也已到来。今年,Vite 的下载量正式超越 webpack。与之相伴,Vitest 的使用量也大幅飙升。现在正是切换到新一代 Vite 工具链的好时机,而 2026 年注定会是全面落地之年—— 随着 Rolldown 稳定版发布,将驱动出更快的新一代 Vite,同时还有一体化的「Vite+」值得期待。

我们的开发工具从未如此优秀。但大家如今真正关心的却是另一个问题:AI 又将带来什么?

AI 即将彻底改变我们查阅文档、编写代码、做架构决策等一系列工作方式。各家公司都在全力押注全新的开发模式。对我们绝大多数人而言,AI 编程助手正在改变我们与代码交互的方式。

这是一件好事吗?

截至 2025 年底,已有近 30% 的代码由 AI 生成。Cursor 的人气暴涨,尽管它们暂时还无法撼动 VS Code 第一 IDE 的地位。而基于智能代理的工具,比如 Claude、Gemini 和 Copilot,也在迅速普及。

对开发者来说,无论使用什么工具,懂得分辨「什么是好代码」 将会比以往任何时候都更重要。紧跟新语言特性、知道该基于哪些库去开发,而非凭感觉从零手写一切,也会变得愈发关键。

现在,一天之内快速搭建新项目、轻松迁移老项目都已成为现实。这对框架和库的作者来说是个挑战。我们必须保证工具能持续服务好开发者,不能指望用户会一直因惯性而使用。

而这一点,恰恰值得所有开发者的期待。

就让我们拭目以待 2026 年的变化吧。我期待着更快的工具、更好的开发体验,以及技术真正成为能力放大器,强化我们自身的判断与选择。

父传子全解析:从基础到实战,新手也能零踩坑

在 Vue3 组件化开发中,父传子是最基础、最常用的组件通信方式,也是新手入门组件通信的第一步。无论是传递简单的字符串、数字,还是复杂的对象、数组,甚至是方法,父传子都有清晰、规范的实现方式。

不同于 Vue2 选项式 API 中 props 的写法,Vue3 组合式 API(

一、核心原理:单向数据流 + Props 传值

Vue3 父传子的核心逻辑只有两个关键词:Props单向数据流

  • Props:父组件通过在子组件标签上绑定属性(类似 HTML 标签属性),将数据传递给子组件;子组件通过定义 props,接收父组件传递过来的数据,相当于子组件的「输入参数」。
  • 单向数据流:数据只能从父组件流向子组件,子组件不能直接修改父组件传递过来的 props 数据(否则会报错)。如果子组件需要修改 props 数据,必须通过子传父的方式,通知父组件修改原始数据。

记住一句话:Props 是只读的,修改需找父组件。这是 Vue 组件通信的核心规范,也是避免数据混乱的关键。

父传子的核心流程(3步走):

  1. 父组件:在使用子组件的标签上,通过 :属性名="要传递的数据" 绑定数据;
  2. 子组件:通过 defineProps 定义要接收的 props(声明属性名和类型,可选但推荐);
  3. 子组件:在模板或脚本中,直接使用 props 中的数据(无需额外导入,直接通过 props.属性名 或 直接写属性名使用)。

二、基础用法:最简洁的父传子实现(必学)

我们用一个「父组件传递基本数据,子组件展示」的简单案例,讲解最基础的父传子写法,代码可直接复制到项目中运行,零门槛上手。

1. 父组件(Parent.vue):绑定数据并传递

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <p>父组件的基本数据:{{ parentName }}、{{ parentAge }}</p>
    <p>父组件的数组:{{ parentList.join('、') }}</p>
    <p>父组件的对象:{{ parentObj.name }} - {{ parentObj.gender }}</p>

    <!-- 1. 核心:在子组件标签上,通过 :属性名 绑定要传递的数据 -->
    <Child 
      :name="parentName"  // 传递字符串
      :age="parentAge"    // 传递数字
      :list="parentList"  // 传递数组
      :user-info="parentObj"  // 传递对象推荐用短横线命名)
    />
  </div>
</template>

<script setup>
// 引入子组件(Vue3 <script setup> 中,引入后可直接在模板中使用)
import Child from './Child.vue'
import { ref, reactive } from 'vue'

// 父组件要传递的数据(涵盖基本类型、数组、对象)
const parentName = ref('张三') // 字符串
const parentAge = ref(25)     // 数字
const parentList = ref(['苹果', '香蕉', '橙子']) // 数组
const parentObj = reactive({  // 对象
  name: '李四',
  gender: '男',
  age: 30
})
</script>

2. 子组件(Child.vue):定义Props并使用

<template>
  <div class="child">
    <h4>我是子组件(接收父组件传递的数据)</h4>
    <p>接收的字符串:{{ name }}</p>
    <p>接收的数字:{{ age }} 岁</p>
    <p>接收的数组:{{ list.join('、') }}</p>
    <p>接收的对象:{{ userInfo.name }}({{ userInfo.gender }})</p>
  </div>
</template>

<script setup>
// 2. 核心:通过 defineProps 定义要接收的 props
// 写法1:数组形式(简单场景,只声明属性名,不限制类型)
// const props = defineProps(['name', 'age', 'list', 'userInfo'])

// 写法2:对象形式(推荐,可限制类型、设置默认值、必填校验)
const props = defineProps({
  // 字符串类型
  name: {
    type: String,
    default: '默认用户名' // 默认值(父组件未传递时使用)
  },
  // 数字类型
  age: {
    type: Number,
    default: 18
  },
  // 数组类型(注意:数组/对象的默认值必须用函数返回,避免复用污染)
  list: {
    type: Array,
    default: () => [] // 数组默认值:返回空数组
  },
  // 对象类型(同理,默认值用函数返回)
  userInfo: {
    type: Object,
    default: () => ({}) // 对象默认值:返回空对象
  }
})

// 3. 在脚本中使用 props 数据(通过 props.属性名)
console.log('脚本中使用props:', props.name, props.age)
</script>

3. 基础细节说明(新手必看)

  • defineProps 是 Vue3 内置宏,无需导入,可直接在
  • 父组件传递数据时,属性名推荐用 kebab-case(短横线命名),比如 :user-info,子组件接收时用 camelCase(小驼峰命名),比如 userInfo,Vue 会自动做转换;
  • 数组/对象类型的 props,默认值必须用 函数返回(比如 default: () => []),否则多个子组件会复用同一个默认值,导致数据污染;
  • 子组件模板中可直接使用 props 的属性名(比如{{ name }}),脚本中必须通过 props.属性名 使用(比如 props.name)。

三、进阶用法:优化父传子的体验(实战常用)

基础用法能满足简单场景,但在实际开发中,我们还会遇到「必填校验」「类型多可选」「props 数据转换」等需求,这部分进阶技巧能让你的代码更规范、更健壮,避免后续维护踩坑。

1. Props 校验:必填项 + 多类型 + 自定义校验

通过 defineProps 的对象形式,我们可以对 props 进行全方位校验,避免父组件传递错误类型、遗漏必填数据,提升代码可靠性。

<script setup>
const props = defineProps({
  // 1. 必填项校验(required: true)
  username: {
    type: String,
    required: true, // 父组件必须传递该属性,否则控制台报警告
    default: '' // 注意:required: true 时,default 无效,可省略
  },

  // 2. 多类型校验(type 为数组)
  id: {
    type: [Number, String], // 允许父组件传递数字或字符串类型
    default: 0
  },

  // 3. 自定义校验(validator 函数)
  score: {
    type: Number,
    default: 0,
    // 自定义校验规则:分数必须在 0-100 之间
    validator: (value) => {
      return value >= 0 && value <= 100
    }
  }
})
</script>

说明:校验失败时,Vue 会在控制台打印警告(不影响代码运行),但能帮助我们快速定位问题,尤其适合团队协作场景。

2. Props 数据转换:computed 处理 props 数据

子组件不能直接修改 props 数据,但可以通过 computed 对 props 数据进行转换、格式化,满足子组件的展示需求,不影响原始 props 数据。

<template>
  <div class="child">
    <p>父组件传递的分数:{{ score }}</p>
    <p>转换后的等级:{{ scoreLevel }}</p>
    <p>父组件传递的姓名(大写):{{ upperName }}</p>
  </div>
</template>

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

const props = defineProps({
  score: {
    type: Number,
    default: 0
  },
  name: {
    type: String,
    default: ''
  }
})

// 对 props 分数进行转换:0-60 不及格,60-80 及格,80-100 优秀
const scoreLevel = computed(() => {
  const { score } = props
  if (score >= 80) return '优秀'
  if (score >= 60) return '及格'
  return '不及格'
})

// 对 props 姓名进行格式化:转为大写
const upperName = computed(() => {
  return props.name.toUpperCase()
})
</script>

3. 传递方法:父组件给子组件传递回调函数

父传子不仅能传递数据,还能传递方法(回调函数)。核心用途:子组件通过调用父组件传递的方法,通知父组件修改数据(解决子组件不能直接修改 props 的问题)。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <p>父组件计数器:{{ count }}</p>
    <!-- 传递方法::方法名="父组件方法" -->
    <Child 
      :count="count"
      :addCount="handleAddCount"  // 传递父组件的方法
    />
  </div>
</template>

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

const count = ref(0)

// 父组件的方法(将被传递给子组件)
const handleAddCount = () => {
  count.value++
}
</script>
<!-- 子组件(Child.vue) -->
<template>
  <div class="child">
    <p>子组件接收的计数器:{{ count }}</p>
    <!-- 调用父组件传递的方法 -->
    <button @click="addCount">点击让父组件计数器+1</button>
  </div>
</template>

<script setup>
const props = defineProps({
  count: {
    type: Number,
    default: 0
  },
  // 声明接收父组件传递的方法(type 为 Function)
  addCount: {
    type: Function,
    required: true
  }
})

// 也可以在脚本中调用父组件的方法
const callParentMethod = () => {
  props.addCount()
}
</script>

注意:传递方法时,父组件只需写 :addCount="handleAddCount"(不带括号),子组件调用时再带括号 addCount();如果父组件写 :addCount="handleAddCount()",会导致方法立即执行,而非传递方法本身。

4. 批量传递 props:v-bind 绑定对象

如果父组件需要给子组件传递多个 props,逐个绑定会比较繁琐,这时可以用 v-bind 批量绑定一个对象,子组件只需对应接收即可。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <!-- 批量传递:v-bind="对象",等价于逐个绑定对象的属性 -->
    <Child v-bind="userObj" />
  </div>
</template>

<script setup>
import Child from './Child.vue'
import { reactive } from 'vue'

// 要批量传递的对象
const userObj = reactive({
  name: '张三',
  age: 25,
  gender: '男',
  address: '北京'
})
</script>
<!-- 子组件(Child.vue) -->
<script setup>
// 逐个接收父组件批量传递的 props,和普通 props 接收一致
const props = defineProps({
  name: String,
  age: Number,
  gender: String,
  address: String
})
</script>

四、实战场景:父传子的高频应用(贴合实际开发)

结合实际开发中的高频场景,补充 3 个常用案例,覆盖大部分父传子需求,直接套用即可。

场景1:父组件控制子组件弹窗显示/隐藏

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <button @click="visible = true">打开子组件弹窗</button>
    <!-- 传递弹窗显示状态 + 关闭弹窗的方法 -->
    <ChildModal 
      :visible="visible"
      :closeModal="handleCloseModal"
    />
  </div>
</template>

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

const visible = ref(false)

// 关闭弹窗的方法
const handleCloseModal = () => {
  visible.value = false
}
</script>
<!-- 子组件(ChildModal.vue) -->
<template>
  <div class="modal" v-if="visible">
    <div class="modal-content">
      <h4>子组件弹窗</h4>
      <button @click="closeModal">关闭弹窗</button>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  },
  closeModal: {
    type: Function,
    required: true
  }
})
</script>

场景2:父组件给子组件传递接口数据

实际开发中,父组件通常会请求接口,将接口返回的数据传递给子组件展示,这是最常见的场景之一。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <!-- 加载中状态 -->
    <div v-if="loading">加载中...</div>
    <!-- 接口数据请求成功后,传递给子组件 -->
    <ChildList :list="goodsList" v-else />
  </div>
</template>

<script setup>
import ChildList from './ChildList.vue'
import { ref, onMounted } from 'vue'

const goodsList = ref([])
const loading = ref(false)

// 父组件请求接口
onMounted(async () => {
  loading.value = true
  try {
    const res = await fetch('https://api.example.com/goods')
    const data = await res.json()
    goodsList.value = data.list // 接口返回的列表数据
  } catch (err) {
    console.error('接口请求失败:', err)
  } finally {
    loading.value = false
  }
})
</script>

场景3:子组件复用,父组件传递不同配置

子组件复用是组件化开发的核心优势,通过父传子传递不同的配置,让同一个子组件实现不同的展示效果。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <!-- 同一个子组件,传递不同配置,展示不同效果 -->
    <Button 
      :text="按钮1"
      :type="primary"
      :disabled="false"
    />
    <Button 
      :text="按钮2"
      :type="default"
      :disabled="true"
    />
  </div>
</template>

<script setup>
import Button from './Button.vue'
</script>
<!-- 子组件(Button.vue) -->
<template>
  <button 
    class="custom-btn"
    :class="type === 'primary' ? 'btn-primary' : 'btn-default'"
    :disabled="disabled"
  >
    {{ text }}
  </button>
</template>

<script setup>
const props = defineProps({
  text: {
    type: String,
    required: true
  },
  type: {
    type: String,
    default: 'default',
    validator: (val) => {
      return ['primary', 'default', 'danger'].includes(val)
    }
  },
  disabled: {
    type: Boolean,
    default: false
  }
})
</script>

五、常见坑点避坑指南(新手必看)

很多新手在写父传子时,会遇到「props 接收不到数据」「修改 props 报错」「方法传递后无法调用」等问题,以下是最常见的 5 个坑点,帮你快速避坑。

坑点1:父组件传递数据时,忘记加冒号(:)

错误写法:<Child name="parentName"></Child>(没有冒号,传递的是字符串 "parentName",而非父组件的 parentName 变量);

正确写法:<Child :name="parentName"></Child>(加冒号,传递的是父组件的变量)。

坑点2:子组件直接修改 props 数据

错误写法:props.name = '李四'(直接修改 props,会报错);

正确写法:通过父传子的方法,通知父组件修改原始数据(参考「传递方法」章节),或通过 computed 转换数据(不修改原始 props)。

坑点3:数组/对象 props 的默认值未用函数返回

错误写法:list: { type: Array, default: [] }(直接写数组,会导致多个子组件复用同一个数组,数据污染);

正确写法:list: { type: Array, default: () => [] }(用函数返回数组,每个子组件都会得到一个新的空数组)。

坑点4:传递方法时,父组件带了括号

错误写法:<Child :addCount="handleAddCount()"></Child>(方法立即执行,传递的是方法的返回值,而非方法本身);

正确写法:<Child :addCount="handleAddCount"></Child>(不带括号,传递方法本身)。

坑点5:props 命名大小写不一致

错误写法:父组件 :userInfo="parentObj",子组件接收 userinfo(小写 i);

正确写法:父组件用 kebab-case(:user-info),子组件用 camelCase(userInfo),或保持大小写一致(不推荐)。

六、总结:父传子核心要点回顾

Vue3 父传子的核心就是「Props 传值 + 单向数据流」,记住以下 4 个核心要点,就能应对所有父传子场景:

  1. 基础流程:父组件 :属性名="数据" 绑定 → 子组件 defineProps 接收 → 子组件使用数据;
  2. 核心规范:Props 是只读的,子组件不能直接修改,修改需通过父传子的方法通知父组件;
  3. 进阶技巧:props 校验提升可靠性,computed 转换数据,v-bind 批量传值,传递方法实现双向交互;
  4. 避坑关键:加冒号传递变量、不直接修改 props、数组/对象默认值用函数返回、传递方法不带括号。

父传子是 Vue3 组件通信中最基础、最常用的方式,掌握它之后,再学习子传父、跨层级通信(provide/inject)、全局通信(Pinia)会更轻松。

TinyEngine 2.10 版本发布:零代码 CRUD、云端协作,开发效率再升级!

本文由体验技术团队Hexqi原创。

前言

TinyEngine 是一款面向未来的低代码引擎底座,致力于为开发者提供高度可定制的技术基础设施——不仅支持可视化页面搭建等核心能力,更可通过 CLI 工程化方式实现深度二次开发,帮助团队快速构建专属的低代码平台。

无论是资源编排、服务端渲染、模型驱动应用,还是移动端、大屏端、复杂页面编排场景,TinyEngine 都能灵活适配,成为你构建低代码体系的坚实基石。

最近我们正式发布 TinyEngine v2.10 版本,带来多项功能升级与体验优化:模型驱动、登录鉴权、应用中心等全新特性,同时还有Schema面板与画布节点同步、出码源码即时预览、支持添加自定义 MCP 服务器等功能进行了增强,以让开发协作、页面搭建变得更简单、更高效。

版本特性总览

核心特性

  • 模型驱动:零代码创建 CRUD
  • 多租户与登录鉴权能力
  • 新增应用中心与模板中心

功能增强

  • 出码支持即时查看代码
  • 自定义 MCP 服务器,扩展 AI 助手能力
  • 画布与 Schema 面板支持同步滚动
  • 页面 Schema CSS 字段格式优化
  • 图表物料更新,组件属性配置平铺优化
  • 多项细节优化与 Bug 修复

体验升级

  • 新官网:UI 全面焕新
  • 新文档:域名迁移与样式升级
  • 新演练场:真实前后端,完整功能体验

新特性详解

1. 【核心特性】模型驱动:零代码极速创建CRUD页面(体验版本)

背景与问题

在传统的后台管理系统开发中,创建一个包含表单、表格和完整 CRUD(增删改查) 功能的页面往往需要开发者:

  • 重复配置相似的表单和表格组件
  • 手动绑定数据源、编写事件处理逻辑
  • 数据模型变更时,同步修改多个组件配置

这种重复性劳动不仅耗时,还容易出错。

核心功能

模型驱动特性通过声明式的数据模型配置,自动生成对应的 UI 组件和数据交互逻辑,实现真正的"零代码"生成数据管理页面。

核心模块

模块 功能
模型管理器插件 可视化创建数据模型、配置字段和 API,管理模型
内置模型组件 表单、表格、组合表单+表格,基于模型自动生成表单、表格,或组合生成完整 CRUD 页面
模型绑定配置器组件 为模型生成 UI、绑定 CRUD 逻辑

支持的模型字段类型:String(字符串)、Number(数字)、Boolean(布尔)、Date(日期)、Enum(枚举)、ModelRef(关联模型)

1.png

价值亮点

  • 开发效率大幅提升:通过配置模型即可生成完整的 CRUD 页面,无需手动配置每个组件
  • 后端自动生成:使用默认接口路径时,自动生成数据库表结构和 CRUD 接口
  • UI 与接口自动绑定:拖拽组件选择模型后,UI 自动生成,接口自动绑定,一站式完成前后端搭建
  • 支持嵌套模型:字段可关联其他模型,实现复杂数据结构(如用户关联部门)(后续实现)
  • 标准化输出:基于统一模型生成的 UI 组件保证了一致性
  • 灵活扩展:可自定义字段类型和组件映射

使用场景

  • 后台管理系统的数据管理页面
  • 需要频繁进行增删改查操作的业务场景
  • 需要快速原型的项目

快速上手

1. 创建数据模型

打开模型管理器插件,创建数据模型(如"用户信息"):

  • 配置模型基本信息:中文名称、英文名称、描述
  • 添加模型字段(如姓名、年龄、邮箱等)
  • 配置字段类型、默认值、是否必填等属性

2. 配置接口路径(可选)

创建模型时,可以选择:

  • 使用默认路径:系统自动使用后端模型接口作为基础路径,并在后端自动生成对应的 SQL 表结构和 CRUD 接口
  • 自定义路径:指定自己的接口基础路径,对接已有后端服务

3. 拖拽模型组件到页面

在物料面板中选择模型组件拖拽到画布:

  • 表格模型:生成数据列表
  • 表单模型:生成数据录入表单
  • 页面模型:生成包含搜索、表格、编辑弹窗的完整 CRUD 页面

4. 绑定模型,自动生成

选中组件后,在右侧属性面板:
1) 点击"绑定模型数据",选择刚才创建的模型
2) 系统自动生成 UI 界面
3) 系统自动绑定 CRUD 接口
4) 一站式完成前后端搭建

5. 预览页面

预览即可看到包含搜索、新增、编辑、删除、分页功能的完整数据管理页面。

2.gif

核心流程图

graph LR
    A[创建数据模型] --> B{选择接口路径}
    B -->|默认路径| C[后端自动生成<br/>SQL表结构+CRUD接口]
    B -->|自定义路径| D[对接已有后端]
    C --> E[拖拽模型组件到页面]
    D --> E
    E --> F[绑定模型]
    F --> G[系统自动生成UI]
    F --> H[系统自动绑定接口]
    G --> I[预览完整CRUD页面]
    H --> I

    style A fill:#e1f5fe
    style C fill:#fff3e0
    style G fill:#f3e5f5
    style H fill:#f3e5f5
    style I fill:#e8f5e9

用户只需关注

定义好数据模型,前后端自动生成:

  • ✅ 无需手动编写表单 HTML
  • ✅ 无需手动编写表格渲染逻辑
  • ✅ 无需手动编写 API 调用代码
  • ✅ 无需手动编写数据验证规则
  • ✅ 无需手动编写分页排序逻辑

让用户专注于业务逻辑和模型设计,而非重复的 UI 代码编写。

2. 【核心特性】多租户与登录鉴权能力

功能概述

TinyEngine v2.10 引入了完整的用户认证系统,支持用户登录、注册、密码找回,并结合多租户体系,让您的设计作品可以实现云端保存、多设备同步和团队协作。

登录注册

  • 用户登录:基于用户名/密码的身份认证,Token 自动管理
  • 用户注册:支持新用户注册,注册成功后提供账户恢复码用于找回密码
  • 密码找回:通过账户恢复码重置密码,无需邮件验证

3.png

组织管理

  • 多组织支持:用户可属于多个组织,灵活切换不同工作空间
  • 组织切换:动态切换组织上下文,组织间数据隔离
  • 创建组织:一键创建新组织,邀请团队成员加入

4.png

登录鉴权流程

系统采用 Token 认证机制,通过 HTTP 拦截器实现统一的请求处理和权限验证:

sequenceDiagram
    participant 用户
    participant 前端应用
    participant HTTP拦截器
    participant 后端API

    用户->>前端应用: 1. 输入用户名/密码登录
    前端应用->>后端API: 2. POST /platform-center/api/user/login
    后端API-->>前端应用: 3. 返回 Token
    前端应用->>前端应用: 4. 保存 Token 到 localStorage

    Note over 前端应用,后端API: 后续请求自动携带 Token

    前端应用->>HTTP拦截器: 5. 发起业务请求
    HTTP拦截器->>HTTP拦截器: 6. 检查 Token 并注入 Authorization 头
    HTTP拦截器->>后端API: 7. 携带 Token 的请求
    后端API-->>HTTP拦截器: 8. 返回数据 或 认证失败(401)

    alt 认证失败
        HTTP拦截器->>前端应用: 9. 清除 Token,显示登录弹窗
        前端应用->>用户: 10. 提示重新登录
    end

访问入口

1)登录界面:访问 TinyEngine 时系统会自动弹出登录窗口,未登录用户需完成登录或注册。

2)组织切换:登录后可通过以下方式切换组织:

  • 点击顶部工具栏的用户头像,选择「切换组织」
  • 在用户菜单中直接选择目标组织

3)退出/重新登录:已登录用户可以点击右上角头像在菜单点击"退出登录",进入登录页面重新登录

使用场景

1)个人使用:登录后即可享受云端保存、多设备同步等功能,设计作品永不丢失。

2)团队协作

  • 创建组织:为团队或项目创建独立组织空间
  • 数据隔离:不同组织的资源完全隔离,清晰区分个人与团队项目

💡 提示:新注册用户默认属于 public 公共组织,所有数据公共可见,您也可以创建自定义组织隔离数据。

开发者指南

1)环境配置

  • 开发环境:通过 pnpm dev:withAuth 命令启用登录认证,pnpm dev 默认不启用(mock server)
  • 生产环境:自动启用完整登录认证系统

也可以修改配置文件来启动或关闭登录鉴权:

export default {
  // enableLogin: true // 打开或关闭登录认证
}

2)多租户机制

  • 用户可属于多个组织,通过 URL 参数标识当前组织上下文
  • 组织间数据完全隔离,切换组织可查看不同资源
  • 当 URL 未携带应用 ID 或组织 ID 时,系统自动跳转到应用中心

3. 【核心特性】应用中心与模板中心

应用中心和模板中心是此次版本新增的两大核心功能模块。通过应用中心可以集中管理您创建的所有低代码应用,为不同的场景创建不同的应用;模板中心则让优秀页面设计得以沉淀为可复用资产,团队成员可以基于模板快速搭建新页面,大幅提升协作效率。

应用中心

登录后进入应用中心,集中管理您创建的所有低代码应用。

功能亮点

  • 统一管理:在一个界面查看、创建、打开所有应用
  • 快速切换:无需手动输入 URL,一键进入任意应用编辑器
  • 组织隔离:不同组织的应用数据隔离,清晰区分个人与团队项目

5.png

模板中心

模板中心让页面设计资产得以沉淀和复用,提升团队协作效率。

核心价值

  • 设计复用:保存优秀页面设计为模板,避免重复造轮子
  • 快速启动:基于模板创建新页面,继承已有布局和样式
  • 团队共享:组织内共享设计资产,统一 UI 风格和设计规范

6.png

7.png

访问入口

在编辑器中点击左上角菜单按钮,悬停即可看到应用中心模板中心入口,点击即可前往。

使用说明

自动跳转规则

  • 如果访问编辑器时未携带应用 ID 或组织 ID 参数,系统会自动跳转到应用中心
  • 您可以在应用中心创建新应用,或打开已有应用进入编辑器

组织权限说明

  • public 组织:默认公共组织,所有用户的应用对所有人可见
  • 自定义组织:用户新建的组织默认仅创建者可见,需手动邀请成员加入
  • 切换组织可以查看不同组织下的应用和资源

特性开关

如果不需要使用应用中心与模板中心,可以在注册表中进行关闭:

// registry.js
export default {
  [META_APP.AppCenter]: false, // 关闭应用中心
  [META_APP.TemplateCenter]: false // 关闭模板中心
  // ...
}

4. 【增强】出码即时预览 - 导出前预览所见即所得

出码功能新增源码预览能力,用户在导出代码前可以实时查看生成的源码内容,提升代码导出体验和准确性。

功能特性

  • 左右分栏布局:左侧树形文件列表,右侧 Monaco 代码编辑器预览
  • 智能初始化:打开对话框时自动显示当前编辑页面对应的文件代码
  • 实时预览:点击树形列表中的任意文件,即可在右侧预览其代码内容
  • 灵活选择:支持勾选需要导出的文件

使用方法

1) 在编辑器中点击「出码」按钮
2) 打开的弹窗中左侧树形列表显示所有可生成的文件,当前页面对应文件自动展示在右侧
3) 点击任意文件预览源码,勾选需要导出的文件
4) 点击「确定」选择保存目录完成导出

8.png

5. 【增强】自定义 MCP 服务器 - 扩展 AI 助手能力

之前版本中,TinyEngine已经提供内置MCP 服务,可以通过MCP工具让AI调用平台提供的各种能力。 本次特性是在TinyEngine 中支持添加自定义 MCP (Model Context Protocol) 服务器,可以通过配置轻松集成第三方 MCP 服务,扩展 AI 开发助手的工具能力。

功能特性

  • 灵活配置:通过注册表简单的配置即可添加自定义服务器
  • 协议支持:支持 SSE 和 StreamableHttp 两种传输协议
  • 服务管理:在 AI 插件的配置界面即可管理 MCP 服务器的开关状态
  • 工具控制:可查看并切换各个工具的启用状态

使用步骤

1) 准备您的 MCP 服务器(需符合 MCP 协议规范

2) 在项目的 registry.js 中添加配置

// 使用示例
// registry.js
export default {
  [META_APP.Robot]: {
    options: {
      mcpConfig: {
        mcpServers: {
          'my-custom-server': {
            type: 'SSE',              // 支持 'SSE' 或 'StreamableHttp'
            url: 'https://your-server.com/sse',
            name: '我的自定义服务器',
            description: '提供xxx功能的工具',
            icon: 'https://your-icon.png'  // 可选
          }
        }
      }
    }
  }
}

3) 刷新编辑器,在 AI 插件 MCP 管理面板中即可看到新添加的服务器

9.png

4) 启用服务器,选择需要的工具,即可在 AI 助手中开始使用!

场景示例

您可以集成企业内部 MCP 服务、社区 MCP 服务、第三方 MCP 工具等,扩展 AI 助手的业务能力。

例如,下面是一个添加图片搜索MCP服务后使用AI生成带图片页面的场景示例:

10.gif

6. 【增强】画布与 Schema 面板支持同步滚动

Schema 面板新增"跟随画布"功能,启用后当在画布中选中组件时,Schema 面板会自动滚动到选中组件的对应位置并高亮显示。

使用场景

  • 快速定位:当页面元素较多时,能快速找到对应组件的 Schema 配置
  • 双向对照:可视化视图与 JSON 代码视图对照,便于理解页面结构

使用方法

打开 Schema 面板,勾选面板标题栏的"跟随画布"复选框启用。在画布中点击切换元素,即可看到 Schema 面板跟随变化。

效果如下:

11.gif

7. 【优化】页面 Schema CSS 字段格式优化

页面 Schema 中的 CSS 样式字段由字符串格式优化为对象格式,提升样式配置的可读性和可维护性。系统会自动处理对象与字符串的相互转换,出码时自动转换为标准 CSS 字符串格式,同时完美兼容之前的字符串格式。

优化场景

  • AI场景更友好:AI生成代码及修改样式场景,能够更快速地进行增量生成及修改
  • 编辑更直观:对象格式支持属性智能提示和语法高亮,编辑体验更佳
  • 阅读更清晰:结构化的对象格式易于查看和修改样式属性
  • 维护更便捷:新增或修改样式规则时,无需手动拼接 CSS 字符串

格式对比

之前(字符串格式)

"css": ".page-base-style { padding: 24px; background: #FFFFFF; } .block-base-style { margin: 16px; } .component-base-style { margin: 8px; }"

现在(对象格式)

"css": {
  ".page-base-style": {
    "padding": "24px",
    "background": "#FFFFFF"
  },
  ".block-base-style": {
    "margin": "16px"
  },
  ".component-base-style": {
    "margin": "8px"
  }
}

兼容性说明

  • 两种格式完全兼容,可在同一项目中混用
  • 系统自动识别格式类型并进行转换
  • 出码时统一转换为标准 CSS 字符串格式
  • 页面样式设置等场景使用都与之前保持一致,不受该特性影响

8. 【增强】图表物料更新,组件属性优化

图表物料进行了如下优化:

  • 添加三种常用图表组件物料:仪表盘、拓扑图、进度图
  • 图表组件的配置面板优化,将原有的图标配置属性由整体 options 配置拆分为独立的属性配置项(颜色、数据、坐标轴等),使配置更加清晰直观。

12.png

9. 【新体验】新演练场 - 完整的前后端体验

演练场进行了全面升级,从原来的前端 Mock 数据改为完整的前后端部署,带来真实的体验环境。

升级亮点

  • 完整的前后端部署:不再是拦截接口 Mock 数据,而是真实的服务端环境
  • 支持用户登录:可以使用真实账户登录演练场
  • 数据隔离:用户数据基于租户进行共享或隔离,更符合实际使用场景
  • 功能完整体验:之前无法体验的功能现在都可以正常使用,如AI助手插件自然语言生成页面

新演练场地址playground.opentiny.design/tiny-engine…

13.png

通过下面两个入口都可以访问:

如您希望继续使用旧版演练场,依旧可以通过下面地址继续访问: 旧版演练场:opentiny.design/tiny-engine…

10. 【新体验】新官网 - UI 全面焕新

TinyEngine 官网首页 UI 全面焕新,带来更现代、更清爽的视觉体验。

  • 全新设计:首页内容刷新,并采用现代化的设计语言,视觉更加清爽美观
  • 响应式布局:完美适配各种屏幕尺寸,移动端访问更友好

访问新版官网:opentiny.design/tiny-engine

14.png

11.【新体验】新文档 - 全新文档体验

TinyEngine 文档与其他OpenTiny产品文档统一迁移至新docs子域名:

新域名docs.opentiny.design/tiny-engine…

文档变化:

  • 整体更统一,方便查找切换其他文档
  • 同时也进行了全面的样式优化,阅读体验更佳

15.png

12. 【其他】功能细节优化&bug修复

结语

回首这一年,TinyEngine 在开源社区的成长离不开每一位开发者和贡献者的支持。v2.10 版本作为春节前的最后一次发布,我们为大家带来了多项重磅特性:

特性 核心价值
模型驱动 零代码 CRUD,开发效率跃升
多租户与登录鉴权 云端协作、团队协作
应用中心与模板中心 应用管理、资产沉淀
出码预览 导出前预览,提升代码导出体验
自定义 MCP 扩展 AI 能力,集成企业服务
Schema 面板同步滚动 画布与代码视图联动
CSS 字段格式优化 对象格式,可读性更强
图表物料更新 配置平铺,更直观
新演练场 真实前后端,完整体验
新官网/文档 UI 焕新,体验升级

致谢

本次版本的开发和问题修复诚挚感谢各位贡献者的积极参与!同时邀请大家加入开源社区的建设,让 TinyEngine 在新的一年里成长得更加优秀和茁壮!

新春祝福

值此新春佳节即将到来之际,TinyEngine 团队衷心祝愿大家:

🧧 新年快乐,万事如意! 🧧

愿新的一年里:

  • 代码如诗行云流水
  • 项目如期顺利上线
  • Bug 远离,需求清晰
  • 团队协作高效顺畅
  • 事业蒸蒸日上,生活幸福美满!

🎊 春节快乐,阖家幸福! 🎊

让我们在春节后带着满满的热情和能量,继续在未来道路上探索前行!

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyEngine源码:github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyPro、TinyNG、TinyCLI、TinyEditor
如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

Vue中默认插槽、具名插槽、作用域插槽如何区分与使用?

一、插槽的基本概念

在Vue组件化开发中,插槽(Slot)是一种强大的内容分发机制,它允许父组件向子组件传递任意模板内容,让子组件的结构更加灵活和可复用。你可以把插槽想象成子组件中预留的“占位符”,父组件可以根据需要在这些占位符中填充不同的内容,就像给积木玩具替换不同的零件一样。

插槽的核心思想是组件的结构与内容分离:子组件负责定义整体结构和样式,父组件负责提供具体的内容。这种设计让组件能够适应更多不同的场景,同时保持代码的可维护性。

二、默认插槽:最简单的内容分发

2.1 什么是默认插槽

默认插槽是最基础的插槽类型,它没有具体的名称,父组件传递的所有未指定插槽名的内容都会被渲染到默认插槽的位置。

2.2 基础使用示例

子组件(FancyButton.vue)

<template>
  <button class="fancy-btn">
    <slot></slot> <!-- 插槽出口:父组件的内容将在这里渲染 -->
  </button>
</template>

<style scoped>
.fancy-btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  background-color: #42b983;
  color: white;
  cursor: pointer;
}
</style>

父组件使用

<template>
  <FancyButton>
    Click me! <!-- 插槽内容:将被渲染到子组件的slot位置 -->
  </FancyButton>
</template>

最终渲染出的HTML结构:

<button class="fancy-btn">Click me!</button>

2.3 为插槽设置默认内容

在父组件没有提供任何内容时,我们可以为插槽设置默认内容,确保组件在任何情况下都能正常显示。

子组件(SubmitButton.vue)

<template>
  <button type="submit" class="submit-btn">
    <slot>Submit</slot> <!-- 默认内容:当父组件没有传递内容时显示 -->
  </button>
</template>

父组件使用

<template>
  <!-- 不传递内容,显示默认的"Submit" -->
  <SubmitButton />
  
  <!-- 传递内容,覆盖默认值 -->
  <SubmitButton>Save Changes</SubmitButton>
</template>

三、具名插槽:精准控制内容位置

3.1 为什么需要具名插槽

当组件的结构比较复杂,包含多个需要自定义的区域时,默认插槽就不够用了。这时我们可以使用具名插槽,为每个插槽分配唯一的名称,让父组件能够精准地控制内容渲染到哪个位置。

3.2 基础使用示例

子组件(BaseLayout.vue)

<template>
  <div class="layout-container">
    <header class="layout-header">
      <slot name="header"></slot> <!-- 具名插槽:header -->
    </header>
    <main class="layout-main">
      <slot></slot> <!-- 默认插槽:未指定名称的内容将在这里渲染 -->
    </main>
    <footer class="layout-footer">
      <slot name="footer"></slot> <!-- 具名插槽:footer -->
    </footer>
  </div>
</template>

<style scoped>
.layout-container {
  max-width: 1200px;
  margin: 0 auto;
}
.layout-header {
  padding: 16px;
  border-bottom: 1px solid #eee;
}
.layout-main {
  padding: 24px;
}
.layout-footer {
  padding: 16px;
  border-top: 1px solid #eee;
  text-align: center;
}
</style>

父组件使用

<template>
  <BaseLayout>
    <!-- 使用#header简写指定内容渲染到header插槽 -->
    <template #header>
      <h1>我的博客</h1>
    </template>
    
    <!-- 未指定插槽名的内容将渲染到默认插槽 -->
    <article>
      <h2>Vue插槽详解</h2>
      <p>这是一篇关于Vue插槽的详细教程...</p>
    </article>
    
    <!-- 使用#footer简写指定内容渲染到footer插槽 -->
    <template #footer>
      <p>© 2025 我的博客 版权所有</p>
    </template>
  </BaseLayout>
</template>

3.3 动态插槽名

Vue还支持动态插槽名,你可以使用变

往期文章归档
免费好用的热门在线工具
量来动态指定要渲染的插槽:
<template>
  <BaseLayout>
    <template #[dynamicSlotName]>
      <p>动态插槽内容</p>
    </template>
  </BaseLayout>
</template>

<script setup>
import { ref } from 'vue'
const dynamicSlotName = ref('header') // 可以根据需要动态修改
</script>

四、作用域插槽:子组件向父组件传递数据

4.1 什么是作用域插槽

在之前的内容中,我们了解到插槽内容只能访问父组件的数据(遵循JavaScript的词法作用域规则)。但在某些场景下,我们希望插槽内容能够同时使用父组件和子组件的数据,这时就需要用到作用域插槽

作用域插槽允许子组件向插槽传递数据,父组件可以在插槽内容中访问这些数据。

4.2 基础使用示例

子组件(UserItem.vue)

<template>
  <div class="user-item">
    <!-- 向插槽传递user对象作为props -->
    <slot :user="user" :isAdmin="isAdmin"></slot>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const user = ref({
  name: '张三',
  age: 28,
  avatar: 'https://via.placeholder.com/60'
})
const isAdmin = ref(true)
</script>

父组件使用

<template>
  <!-- 使用v-slot指令接收插槽props -->
  <UserItem v-slot="slotProps">
    <img :src="slotProps.user.avatar" alt="用户头像" class="avatar">
    <div class="user-info">
      <h3>{{ slotProps.user.name }}</h3>
      <p>年龄:{{ slotProps.user.age }}</p>
      <span v-if="slotProps.isAdmin" class="admin-tag">管理员</span>
    </div>
  </UserItem>
</template>

4.3 解构插槽Props

为了让代码更简洁,我们可以使用ES6的解构语法直接提取插槽Props:

<template>
  <UserItem v-slot="{ user, isAdmin }">
    <img :src="user.avatar" alt="用户头像" class="avatar">
    <div class="user-info">
      <h3>{{ user.name }}</h3>
      <p>年龄:{{ user.age }}</p>
      <span v-if="isAdmin" class="admin-tag">管理员</span>
    </div>
  </UserItem>
</template>

4.4 具名作用域插槽

具名插槽也可以传递Props,父组件需要在对应的具名插槽上接收:

子组件

<template>
  <div class="card">
    <slot name="header" :title="cardTitle"></slot>
    <slot :content="cardContent"></slot>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const cardTitle = ref('卡片标题')
const cardContent = ref('这是卡片的内容...')
</script>

父组件

<template>
  <Card>
    <template #header="{ title }">
      <h2>{{ title }}</h2>
    </template>
    
    <template #default="{ content }">
      <p>{{ content }}</p>
    </template>
  </Card>
</template>

五、课后Quiz

题目

  1. 什么是默认插槽?请给出一个简单的使用示例。
  2. 具名插槽的主要作用是什么?如何在父组件中指定内容渲染到具名插槽?
  3. 作用域插槽解决了什么问题?请描述其工作原理。
  4. 如何为插槽设置默认内容?

答案解析

  1. 默认插槽是组件中没有指定名称的插槽,父组件传递的未指定插槽名的内容会被渲染到默认插槽的位置。示例:

    <!-- 子组件 -->
    <button><slot></slot></button>
    <!-- 父组件 -->
    <Button>点击我</Button>
    
  2. 具名插槽用于组件包含多个需要自定义的区域的场景,每个插槽有唯一的名称,父组件可以精准控制内容的渲染位置。父组件使用<template #插槽名>的语法传递内容到指定的具名插槽。

  3. 作用域插槽解决了插槽内容无法访问子组件数据的问题。工作原理:子组件在插槽出口上传递Props(类似组件Props),父组件使用v-slot指令接收这些Props,从而在插槽内容中访问子组件的数据。

  4. <slot>标签之间写入默认内容即可,当父组件没有传递内容时,默认内容会被渲染:

    <slot>默认内容</slot>
    

六、常见报错解决方案

1. 报错:v-slot指令只能用在<template>或组件标签上

原因v-slot指令只能用于<template>标签或组件标签,不能直接用于普通HTML元素。 解决办法:将v-slot指令移到<template>标签或组件标签上。例如:

<!-- 错误写法 -->
<div v-slot="slotProps">{{ slotProps.text }}</div>

<!-- 正确写法 -->
<template v-slot="slotProps">
  <div>{{ slotProps.text }}</div>
</template>

2. 报错:未定义的插槽Props

原因:父组件尝试访问子组件未传递的插槽Props。 解决办法

  • 确保子组件在插槽出口上传递了对应的Props;
  • 在父组件中使用可选链操作符(?.)避免报错:
    <MyComponent v-slot="{ text }">
      {{ text?.toUpperCase() }} <!-- 使用可选链操作符 -->
    </MyComponent>
    

3. 报错:具名插槽的内容未显示

原因

  • 父组件传递具名插槽内容时,插槽名拼写错误;
  • 子组件中没有定义对应的具名插槽。 解决办法
  • 检查插槽名是否拼写正确(注意大小写敏感);
  • 确保子组件中定义了对应的具名插槽:<slot name="header"></slot>

4. 报错:默认插槽和具名插槽同时使用时的作用域混淆

原因:当同时使用默认插槽和具名插槽时,直接为组件添加v-slot指令会导致编译错误,因为默认插槽的Props作用域会与具名插槽混淆。 解决办法:为默认插槽使用显式的<template>标签:

<!-- 错误写法 -->
<MyComponent v-slot="{ message }">
  <p>{{ message }}</p>
  <template #footer>
    <p>{{ message }}</p> <!-- message 属于默认插槽,此处不可用 -->
  </template>
</MyComponent>

<!-- 正确写法 -->
<MyComponent>
  <template #default="{ message }">
    <p>{{ message }}</p>
  </template>
  
  <template #footer>
    <p>页脚内容</p>
  </template>
</MyComponent>

参考链接

cn.vuejs.org/guide/compo…

useStorage:本地数据持久化利器

image

前言

一、基础概念

1.1 什么是本地存储

  在Web开发中,本地存储是指将数据存储在客户端浏览器中,以便在不同的页面或会话之间保持数据的持久性。本地存储可以帮助我们存储用户的偏好设置、临时数据以及其他需要在用户关闭浏览器后仍然存在的数据。对浏览器来说,使用 Web Storage 存储键值对比存储 Cookie 方式更直观,而且容量更大,它包含两种:localStorage 和 sessionStorage

Cookie localStorage sessionStorage
数据的生命期 一般由服务器生成,可设置失效时间。
如果在浏览器端生成Cookie,默认是关闭浏览器后失效
除非被清除,否则永久保存,
可变相设置失效时间
仅在当前会话下有效,
关闭页面或浏览器后被清除
存放数据大小 4K左右 一般为5MB
与服务器端通信 每次都会携带在HTTP头中,
如果使用cookie保存过多数据会带来性能问题
仅在客户端(即浏览器)中保存,
不参与和服务器的通信
易用性 源生的Cookie接口不友好,需要自己封装 源生接口可以接受,亦可再次封装

1.2 useStorage 简介

  useStorage 是 Vue 用于数据持久化的核心工具,它能够自动将响应式数据同步到 localStorage 或 sessionStorage 中。这个功能对于需要保存用户偏好设置、表单数据或应用状态的场景特别有用。这样,我们就可以在Vue组件中方便地使用本地存储来持久化数据,提供更好的用户体验和数据管理能力。

// hooks/useStorage.ts
/**
 * 获取传入的值的类型
 */
const getValueType = (value: any) => {
    const type = Object.prototype.toString.call(value)
    return type.slice(8, -1)
}

export const useStorage = (type: 'sessionStorage' | 'localStorage' = 'sessionStorage') => {
    /**
     * 存储数据
     * @param key
     * @param value
     */
    const setStorage = (key: string, value: any) => {
        const valueType = getValueType(value)
        window[type].setItem(key, JSON.stringify({type: valueType, value}))
    }
    /**
     * 获取某个存储数据
     * @param key
     */
    const getStorage = (key: string) => {
        const value = window[type].getItem(key)
        if (value) {
            const {value: val} = JSON.parse(value)
            return val
        } else {
            return value
        }
    }

    /**
     * 清除某个存储数据
     * @param key
     */
    const removeStorage = (key: string) => {
        window[type].removeItem(key)
    }

    /**
     * 清空所有存储数据,如果需要排除某些数据,可以传入 excludes 来排除
     * @param excludes 排除项。如:clear(['key']),这样 key 就不会被清除
     */
    const clear = (excludes?: string[]) => {
        // 获取排除项
        const keys = Object.keys(window[type])
        const defaultExcludes = ['dynamicRouter', 'serverDynamicRouter']
        const excludesArr = excludes ? [...excludes, ...defaultExcludes] : defaultExcludes
        const excludesKeys = excludesArr ? keys.filter((key) => !excludesArr.includes(key)) : keys
        // 排除项不清除
        excludesKeys.forEach((key) => {
            window[type].removeItem(key)
        })
        // window[type].clear()
    }

    return {
        setStorage,
        getStorage,
        removeStorage,
        clear
    }
}

二、使用帮助

2.1 用法

<script setup lang="ts">
import { useStorage } from "@/hooks/useStorage";

const { setStorage, getStorage, removeStorage, clear } = useStorage();
// const { setStorage, getStorage, removeStorage, clear } = useStorage('localStorage');
</script>

  useStorage 提供了四个核心函数来操作数据,如下表所示。

方法名 简要说明
setStorage 存储数据。将要用于引用的键名作为第一个参数传递,将要保存的值作为第二个参数传递。
getStorage 获取某个存储数据
removeStorage 清除某个存储数据
clear 清除所有缓存数据,如果需要排除某些数据,可以传入 excludes 来排除,如:clear(['key']),这样 key 就不会被清除

2.2 储存数据

  使用 setStorage 方法可以将数据进行持久化存储,例如:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { setStorage } = useStorage();
setStorage('accessToken', 'Bearer ' + response.data.result.accessToken);
</script>

  这里,accessToken是键,Bearer + response.data.result.accessToken 是对应的值。除此以外,支持非字符串类型存取值:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { setStorage } = useStorage();
  
setStorage('key', { name: 'Jok' })
</script>

  注意:由于 localStorage 操作的是字符串,如果存储的是JSON对象,需要先使用 JSON.stringify() 将其转换为字符串,取回时再使用 JSON.parse() 还原。

2.3 取出数据

  获取存储的数据则使用 getStorage 方法:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { getStorage } = useStorage();
const accessToken = getStorage('accessToken');
</script>

2.4 删除数据

  如果需要移除某个键值对,可以调用 removeStorage 方法:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { removeStorage } = useStorage();
removeStorage('key')
</script>

2.5 更改数据

  要更新已存储的数据,同样使用 setStorage 方法,覆盖原有的值:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { setStorage } = useStorage();
getStorage('accessToken', '更改后' + response.data.result.accessToken);
</script>

2.6 清除数据

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { clear } = useStorage();
clear()
</script>

三、总结

  Vue 中使用 localStorage 可以方便地在用户关闭和重新打开浏览器时保持应用状态,解决像 Cookie 那样需要刷新才能获取新值的问题。合理运用 localStorage 和 sessionStorage,可以在不增加服务器负担的情况下,提供更好的用户体验。

image

Vue3 子传父全解析:从基础用法到实战避坑

在 Vue3 开发中,组件通信是绕不开的核心场景,而子传父作为最基础、最常用的通信方式之一,更是新手入门必掌握的知识点。不同于 Vue2 的 $emit 写法,Vue3 组合式 API(<script setup>)简化了子传父的实现逻辑,但也有不少细节和进阶技巧需要注意。

本文将抛开 TypeScript,用最通俗的语言 + 可直接复制的实战代码,从基础用法、进阶技巧、常见场景到避坑指南,全方位讲解 Vue3 子传父,新手看完就能上手,老手也能查漏补缺。

一、核心原理:子组件触发事件,父组件监听事件

Vue3 子传父的核心逻辑和 Vue2 一致:子组件通过触发自定义事件,将数据传递给父组件;父组件通过监听该自定义事件,接收子组件传递的数据

关键区别在于:Vue3 <script setup> 中,无需通过 this.$emit 触发事件,而是通过 defineEmits 声明事件后,直接调用 emit 函数即可,语法更简洁、更直观。

先记住核心流程,再看具体实现:

  1. 子组件:用 defineEmits 声明要触发的自定义事件(可选但推荐);
  2. 子组件:在需要传值的地方(如点击事件、接口回调),调用 emit('事件名', 要传递的数据)
  3. 父组件:在使用子组件的地方,通过 @事件名="处理函数" 监听事件;
  4. 父组件:在处理函数中,接收子组件传递的数据并使用。

二、基础用法:最简洁的子传父实现(必学)

我们用一个「子组件输入内容,父组件实时显示」的简单案例,讲解基础用法,代码可直接复制到项目中运行。

1. 子组件(Child.vue):声明事件 + 触发事件

<template>
  <div class="child">
    <h4>我是子组件</h4>
    <!-- 输入框输入内容,触发input事件,传递输入值 -->
    <input 
      type="text" 
      v-model="childInput" 
      @input="handleInput"
      placeholder="请输入要传递给父组件的内容"
    />
    <!-- 按钮点击,传递固定数据 -->
    <button @click="handleClick" style="margin-top: 10px;">
      点击向父组件传值
    </button>
  </div>
</template>

<script setup>
// 1. 声明要触发的自定义事件(数组形式,元素是事件名)
// 可选,但推荐声明:增强代码可读性,IDE会有语法提示,避免拼写错误
const emit = defineEmits(['inputChange', 'btnClick'])

// 子组件内部数据
const childInput = ref('')

// 输入框变化时,触发事件并传递输入值
const handleInput = () => {
  // 2. 触发事件:第一个参数是事件名,第二个参数是要传递的数据(可选,可多个)
  emit('inputChange', childInput.value)
}

// 按钮点击时,触发事件并传递固定对象
const handleClick = () => {
  emit('btnClick', {
    name: '子组件',
    msg: '这是子组件通过点击按钮传递的数据'
  })
}
</script>

2. 父组件(Parent.vue):监听事件 + 接收数据

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <p>子组件输入的内容:{{ parentMsg }}</p>
    <p>子组件点击传递的数据:{{ parentData }}</p>
    
    <!-- 3. 监听子组件声明的自定义事件,绑定处理函数 -->
    <Child 
      @inputChange="handleInputChange"
      @btnClick="handleBtnClick"
    />
  </div>
</template>

<script setup>
// 引入子组件
import Child from './Child.vue'
import { ref, reactive } from 'vue'

// 父组件接收数据的容器
const parentMsg = ref('')
const parentData = reactive({
  name: '',
  msg: ''
})

// 4. 处理子组件触发的inputChange事件,接收传递的数据
const handleInputChange = (val) => {
  // val 就是子组件emit传递过来的值(childInput.value)
  parentMsg.value = val
}

// 处理子组件触发的btnClick事件,接收传递的对象
const handleBtnClick = (data) => {
  // data 是子组件传递的对象,直接解构或赋值即可
  parentData.name = data.name
  parentData.msg = data.msg
}
</script>

3. 核心细节说明

  • defineEmits 是 Vue3 内置的宏,无需导入,可直接使用;
  • emit 函数的第一个参数必须和 defineEmits 中声明的事件名一致(大小写敏感),否则父组件无法监听到;
  • emit 可传递多个参数,比如 emit('event', val1, val2),父组件处理函数可对应接收 (val1, val2) => {}
  • 父组件监听事件时,可使用 @事件名(简写)或 v-on:事件名(完整写法),效果一致。

三、进阶用法:优化子传父的体验(实战常用)

基础用法能满足简单场景,但在实际开发中,我们还会遇到「事件校验」「双向绑定」「事件命名规范」等需求,这部分进阶技巧能让你的代码更规范、更健壮。

1. 事件校验:限制子组件传递的数据类型

通过 defineEmits 的对象形式,可对事件传递的数据进行类型校验,避免子组件传递错误类型的数据,提升代码可靠性(类似 props 校验)。

<script setup>
// 对象形式声明事件,key是事件名,value是校验函数(参数是子组件传递的数据,返回boolean)
const emit = defineEmits({
  // 校验inputChange事件传递的数据必须是字符串
  inputChange: (val) => {
    return typeof val === 'string'
  },
  // 校验btnClick事件传递的数据必须是对象,且包含name和msg属性
  btnClick: (data) => {
    return typeof data === 'object' && 'name' in data && 'msg' in data
  }
})

// 若传递的数据不符合校验,控制台会报警告(不影响代码运行,仅提示)
const handleInput = () => {
  emit('inputChange', 123) // 传递数字,不符合校验,控制台报警告
}
</script>

2. 双向绑定:v-model 简化子传父(高频场景)

很多时候,子传父是为了「修改父组件的数据」,比如表单组件、开关组件,这时可使用 v-model 简化代码,实现父子组件双向绑定,无需手动声明事件和处理函数。

Vue3 中,v-model 本质是「语法糖」,等价于 :modelValue="xxx" @update:modelValue="xxx = $event"

优化案例:子组件开关,父组件显示状态

<!-- 子组件(Child.vue) -->
<template>
  <div class="child">
    <h4>子组件开关</h4>
    <button @click="handleSwitch">
      {{ isOpen ? '关闭' : '打开' }}
    </button>
  </div>
</template>

<script setup>
// 1. 接收父组件通过v-model传递的modelValue
const props = defineProps(['modelValue'])
// 2. 声明update:modelValue事件(固定命名,不可修改)
const emit = defineEmits(['update:modelValue'])

// 子组件内部使用父组件传递的值
const isOpen = computed(() => props.modelValue)

// 开关切换,触发事件,修改父组件数据
const handleSwitch = () => {
  emit('update:modelValue', !isOpen.value)
}
</script>
<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <h3>父组件:{{ isSwitchOpen ? '开关已打开' : '开关已关闭' }}</h3>
    <!-- 直接使用v-model,无需手动监听事件 -->
    <Child v-model="isSwitchOpen" />
  </div>
</template>

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

const isSwitchOpen = ref(false)
</script>

扩展:多个 v-model 双向绑定

Vue3 支持给同一个子组件绑定多个 v-model,只需给 v-model 加后缀,对应子组件的propsemit 即可。

<!-- 父组件 -->
<Child 
  v-model:name="parentName" 
  v-model:age="parentAge" 
/>

<!-- 子组件 -->
<script setup>
// 接收多个v-model传递的props
const props = defineProps(['name', 'age'])
// 声明对应的update事件
const emit = defineEmits(['update:name', 'update:age'])

// 触发事件修改父组件数据
emit('update:name', '新名字')
emit('update:age', 25)
</script>

3. 事件命名规范:提升代码可读性

在实际开发中,遵循统一的事件命名规范,能让团队协作更高效,推荐以下规范:

  • 事件名采用「kebab-case 短横线命名」(和 HTML 事件命名一致),比如 input-change 而非 inputChange
  • 事件名要语义化,体现事件的用途,比如 form-submit(表单提交)、delete-click(删除点击);
  • 双向绑定的事件固定为 update:xxx,xxx 对应 props 名,比如 update:nameupdate:visible

四、实战场景:子传父的常见应用

结合实际开发中的高频场景,给大家补充 3 个常用案例,覆盖大部分子传父需求。

场景1:子组件表单提交,父组件接收表单数据

<!-- 子组件(FormChild.vue) -->
<template>
  <div class="form-child">
    <input v-model="form.name" placeholder="请输入姓名" />
    <input v-model="form.age" type="number" placeholder="请输入年龄" />
    <button @click="handleSubmit">提交表单</button>
  </div>
</template>

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

const emit = defineEmits(['form-submit'])

const form = reactive({
  name: '',
  age: ''
})

const handleSubmit = () => {
  // 表单校验(简化)
  if (!form.name || !form.age) return alert('请填写完整信息')
  // 提交表单数据给父组件
  emit('form-submit', form)
  // 提交后重置表单
  form.name = ''
  form.age = ''
}
</script>

场景2:子组件关闭弹窗,父组件控制弹窗显示/隐藏

<!-- 子组件(ModalChild.vue) -->
<template>
  <div class="modal" v-if="visible">
    <div class="modal-content">
      <h4>子组件弹窗</h4>
      <button @click="handleClose">关闭弹窗</button>
    </div>
  </div>
</template>

<script setup>
const props = defineProps(['visible'])
const emit = defineEmits(['close-modal'])

const handleClose = () => {
  // 触发关闭事件,通知父组件隐藏弹窗
  emit('close-modal')
}
</script>

场景3:子组件列表删除,父组件更新列表

<!-- 子组件(ListChild.vue) -->
<template>
  <div class="list-child">
    <div v-for="item in list" :key="item.id">
      {{ item.name }}
      <button @click="handleDelete(item.id)">删除</button>
    </div>
  </div>
</template>

<script setup>
const props = defineProps(['list'])
const emit = defineEmits(['delete-item'])

const handleDelete = (id) => {
  // 传递要删除的id给父组件,由父组件更新列表
  emit('delete-item', id)
}
</script>

五、常见坑点避坑指南(新手必看)

很多新手在写子传父时,会遇到「父组件监听不到事件」「数据传递失败」等问题,以下是最常见的 4 个坑点,帮你快速避坑。

坑点1:事件名大小写不一致

子组件 emit('inputChange'),父组件 @inputchange="handle"(小写),会导致父组件监听不到事件。

解决方案:统一采用 kebab-case 命名,子组件 emit('input-change'),父组件 @input-change="handle"

坑点2:忘记声明事件(defineEmits)

子组件直接调用 emit('event'),未用 defineEmits 声明事件,虽然开发环境可能不报错,但生产环境可能出现异常,且 IDE 无提示。

解决方案:无论事件是否需要校验,都用 defineEmits 声明(数组形式即可)。

坑点3:传递复杂数据(对象/数组)时,父组件修改后影响子组件

子组件传递对象/数组给父组件,父组件直接修改该数据,会影响子组件(因为引用类型传递的是地址)。

解决方案:父组件接收数据后,用 JSON.parse(JSON.stringify(data)) 深拷贝,或用 reactive + toRaw 处理,避免直接修改原始数据。

坑点4:v-model 双向绑定时报错,提示「modelValue 未定义」

原因:子组件未接收 modelValue props,或未声明 update:modelValue 事件。

解决方案:确保子组件 defineProps(['modelValue'])defineEmits(['update:modelValue']) 都声明。

六、总结:子传父核心要点回顾

Vue3 子传父的核心就是「事件触发 + 事件监听」,记住以下 3 个核心要点,就能应对所有场景:

  1. 基础写法:defineEmits 声明事件 → emit 触发事件 → 父组件 @事件名 监听;
  2. 进阶优化:事件校验提升可靠性,v-model 简化双向绑定,遵循 kebab-case 命名规范;
  3. 避坑关键:事件名大小写一致、必声明事件、复杂数据深拷贝、v-model 对应 props 和 emit 命名正确。

子传父是 Vue3 组件通信中最基础的方式,掌握它之后,再学习父传子(props)、跨层级通信(provide/inject)、全局通信(Pinia)会更轻松。

前端侦探:我是如何挖掘出网站里 28 个"隐藏商品"的?

前端侦探:我是如何挖掘出网站里 28 个"隐藏商品"的?

免责声明:本文仅供技术交流与学习,请勿利用文中技术手段对他人的服务器造成压力或进行恶意爬取。所有测试数据均来自公开接口。

🕵️‍♂️ 从一个好奇心开始

前几天逛一个数字产品合租平台(nf.video)时,我发现它首页只孤零零地挂着 6 个商品:Netflix、Disney+、Spotify 等常见的全家桶。

作为一个前端开发者,我的直觉告诉我:事情没这么简单

通常这类平台为了 SEO 或者后台管理的统一性,数据库里往往躺着更多商品,只是因为库存、策略原因被前端"隐藏"了。今天就带大家通过浏览器控制台(Console),用几招前端调试技巧,扒出那些藏在代码背后的秘密。


🔍 第一层:摆在明面上的数据

首先,我们看看普通用户能看到什么。打开控制台,简单查一下 DOM:

// 获取首页所有商品卡片
const cards = document.querySelectorAll('.platFormItem');
console.log(`首页可见商品数: ${cards.length}`);
// 输出: 6

确实只有 6 个。这建立了我们的"基准线"。如果后面我们找到了多于 6 个的数据,那就说明有"隐藏款"。


🎣 第二层:Vue Router 拦截术

点击商品卡片会跳转到购买页。通常我们会看 Network 面板找链接,但这个网站是 SPA(单页应用),点击是路由跳转。

为了不真的跳走(跳走就得退回来,麻烦),我们可以利用 Vue Router 的全局前置守卫来做一个"钩子"。我们想知道点击卡片后,路由到底想带我们去哪?

我们可以直接在控制台注入这段代码:

// 假设挂载在 app 上的 router 实例(视具体项目而定,通常在 vueApp.config 或 __vue_app__ 中)
// 这里演示思路
const router = document.querySelector('#app').__vue_app__.config.globalProperties.$router;

// 👮‍♂️ 注册一个拦截守卫
router.beforeEach((to, from, next) => {
    console.log(`🎯 捕获到目标路由: ${to.fullPath}`);
    console.log(`📦 参数 ID: ${to.params.id}`);
    
    // ✋ next(false) 阻止实际跳转,我们就停在当前页
    next(false); 
});

然后在页面上点击一个"苹果商店"的卡片:

Console 输出: 🎯 捕获到目标路由: /buy/31 📦 参数 ID: 31

Bingo!我们摸清了路由规则:/buy/:id。这意味着商品是以 ID 为索引的。


🕵️ 第三层:Performance API 里的蛛丝马迹

页面加载完了,Network 面板里的请求都被冲掉了或者很难找。这时,浏览器原生的 Performance API 就像一个黑匣子,记录了所有发生过的资源请求。

我想看看前端到底请求了哪些 API 接口:

// 筛选所有 XMLHttpRequest 或 Fetch 请求
const apiRequests = performance.getEntriesByType('resource')
  .filter(e => e.initiatorType === 'xmlhttprequest' || e.initiatorType === 'fetch')
  .map(e => e.name);

console.table(apiRequests);

在一堆日志里,我发现了这几个有趣的接口:

  • /api/applets/goods/get/homeManage (首页数据,估计就那 6 个)
  • /api/applets/goods/get/categoryGoods (分类商品?这个听起来有戏!)

我尝试手动调用了一下这个 categoryGoods 接口:

fetch('/api/applets/goods/get/categoryGoods')
  .then(res => res.json())
  .then(data => console.log(`拿到所有商品数: ${data.data.length}`));
// 输出: 27

27 个! 远超首页的 6 个。

通过分析返回的 JSON,我看到了大量首页没展示的商品:

  • ID 20: MagSafe 三合一无线充
  • ID 96: 银河次时代智能 NAS (这啥黑科技?)
  • ID 111: Typora 正版授权

到这里,如果是普通用户可能就满足了。但作为程序员,我注意到 ID 并不连续。最大的 ID 是 113,但中间缺了很多数字。

那些消失的 ID 去哪了?


🚀 第四层:ID 暴力枚举与深度挖掘

既然知道了 API 模式是 /api/applets/goods/get/:id,且 ID 是数字。那我能不能写个脚本,把 1 到 200 的 ID 全扫一遍?

这就像是在玩"扫雷"。

// 简单的并发探测脚本
async function scanHiddenGoods(maxId) {
    const hiddenGoods = [];
    
    console.log(`🚀 开始扫描 ID 1 - ${maxId}...`);
    
    const promises = [];
    for (let id = 1; id <= maxId; id++) {
        const p = fetch(`/8081/api/applets/goods/get/${id}`)
            .then(res => res.json())
            .then(res => {
                // 如果接口返回成功且有数据
                if (res.code === 10000 && res.data) {
                    return { id, name: res.data.goodsName, price: res.data.price };
                }
                return null;
            })
            .catch(() => null);
        promises.push(p);
    }

    const results = await Promise.all(promises);
    return results.filter(Boolean); // 过滤掉 null
}

// 让我们跑一下
scanHiddenGoods(200).then(goods => {
    console.table(goods);
    console.log(`🎉 共发现商品: ${goods.length} 个`);
});

几秒钟后,控制台打出了一张长长的表格。

结果令人震惊:

除了刚才分类列表里的 27 个,我又挖出了 8 个"幽灵商品"。这些商品连分类 API 都不返回,完全是"隐形"的,只有通过 ID 直达才能看到:

ID 名称 这居然也有?
18 GPT Plus 可能因为合规问题隐藏
26 Midjourney 只能直接访问购买
50 Runway 那个文生视频的 AI
105 Codex 编程神器

这些商品很可能是测试下架的、或者是仅限内部/老客户通过链接购买的。


📝 总结

通过这次探索,我们发现了网站里共有 34 个有效商品,而首页只展示了 17%

回顾一下我们的"作案工具":

  1. DOM 解析:看清表象。
  2. Vue Router 守卫:拦截路由,探知路径规则。
  3. Performance API:回溯历史请求,定位关键后端接口。
  4. Promise.all 并发探测:暴力枚举,发现离散数据。

前端开发不仅仅是画页面,善用浏览器提供的调试工具,我们可以对正在运行的应用有更深层的理解(或者单纯是为了满足好奇心 😉)。


如果你觉得这个分析过程有趣,欢迎点赞收藏!

文件16进制查看器核心JS实现

文件16进制查看器核心JS实现

本文将介绍基于 Vue 3 和 Nuxt 3 实现的“文件16进制查看器”的核心技术方案。该工具主要用于在浏览器端直接查看任意文件(包括二进制文件)的十六进制编码,所有文件处理均在前端完成,不涉及后端上传。

在线工具网址:see-tool.com/file-hex-vi…
工具截图:
在这里插入图片描述

1. 核心工具函数 (utils/file-hex-viewer.js)

我们将核心的文件处理和格式化逻辑封装在 utils/file-hex-viewer.js 中,主要包括文件大小格式化、二进制转换十六进制字符串以及文件名生成。

1.1 文件大小格式化 (formatFileSize)

用于将字节数转换为人类可读的格式(如 KB, MB)。

export function formatFileSize(bytes, units = ['Bytes', 'KB', 'MB', 'GB', 'TB']) {
  if (!Number.isFinite(bytes) || bytes < 0) return `0 ${units[0] || 'Bytes'}`
  if (bytes === 0) return `0 ${units[0] || 'Bytes'}`

  const k = 1024
  const index = Math.floor(Math.log(bytes) / Math.log(k))
  const value = Math.round((bytes / Math.pow(k, index)) * 100) / 100
  const unit = units[index] || units[units.length - 1] || 'Bytes'
  return `${value} ${unit}`
}

1.2 二进制转十六进制 (bytesToHex)

这是本工具的核心转换函数。它接收一个 Uint8Array,并根据传入的 format 参数(支持 spacenospaceuppercase)生成对应的十六进制字符串。对于 space 格式,每16个字节会自动换行,方便阅读。

export function bytesToHex(uint8Array, format = 'space') {
  if (!uint8Array || !uint8Array.length) return ''
  const useUppercase = format === 'uppercase'
  const useSpace = format === 'space'
  let hexString = ''

  for (let i = 0; i < uint8Array.length; i++) {
    // 将每个字节转换为2位十六进制字符串
    let hex = uint8Array[i].toString(16).padStart(2, '0')
    
    if (useUppercase) {
      hex = hex.toUpperCase()
    }
    
    if (useSpace) {
      hexString += `${hex} `
      // 每16个字节插入一个换行符
      if ((i + 1) % 16 === 0) {
        hexString += '\n'
      }
    } else {
      hexString += hex
    }
  }

  return hexString.trim()
}

1.3 导出文件名生成 (buildHexFileName)

根据原文件名和当前的格式设置,生成导出文件的名称(后缀为 .hex.HEX)。

export function buildHexFileName(originalName, format = 'space') {
  if (!originalName) return `file${format === 'uppercase' ? '.HEX' : '.hex'}`
  const lastDot = originalName.lastIndexOf('.')
  const baseName = lastDot > 0 ? originalName.slice(0, lastDot) : originalName
  const extension = format === 'uppercase' ? '.HEX' : '.hex'
  return `${baseName}${extension}`
}

2. 文件读取与处理逻辑

在前端实现十六进制查看器的核心是利用 HTML5 的 FileReader API 读取文件内容为 ArrayBuffer,然后转换为 Uint8Array 进行处理。

const processFile = (file) => {
  const reader = new FileReader()
  
  reader.onload = (event) => {
    try {
      const buffer = event.target.result
      const bytes = new Uint8Array(buffer)
      // 调用工具函数生成 Hex 字符串
      const hex = bytesToHex(bytes, 'space') 
      // 更新视图...
    } catch (error) {
      console.error('Process failed:', error)
    }
  }
  
  reader.onerror = () => {
    console.error('Read error')
  }
  
  // 读取文件为 ArrayBuffer
  reader.readAsArrayBuffer(file)
}

3. 导出与下载功能

为了让用户可以将十六进制编码保存到本地,我们利用 Blob 对象和 URL.createObjectURL 创建临时的下载链接,实现纯前端下载。

const downloadHexFile = (hexContent, originalName, format) => {
  if (!hexContent) return

  const fileName = buildHexFileName(originalName, format)
  // 创建包含 Hex 内容的 Blob
  const blob = new Blob([hexContent], { type: 'text/plain' })
  const url = URL.createObjectURL(blob)
  
  // 创建临时链接并触发下载
  const link = document.createElement('a')
  link.href = url
  link.download = fileName
  document.body.appendChild(link)
  link.click()
  
  // 清理
  document.body.removeChild(link)
  URL.revokeObjectURL(url)
}

总结

该方案的核心在于通过 utils/file-hex-viewer.js 封装纯粹的格式化和转换逻辑,并结合浏览器原生的 FileReaderBlob API 完成文件的读取与导出,实现了一个轻量级且高效的纯前端文件十六进制查看工具。

Vben Admin管理系统集成微前端wujie-(三)终

  1. # Vben Admin管理系统集成qiankun微服务(一)
  2. # Vben Admin管理系统集成qiankun微服务(二)

一、前言

本篇是vben前端框架集成微服务的第3篇,前段时间写了vue-vben-admin集成qiankun的两篇文章,收到了大家不少建议,文章还遗留了一个问题就是多tab标签不支持状态保持,借助AI虽然也实现的相应方案,但是对vben的package包修改内容较多(后续同步主框架较为繁琐),并且修改代码健状性不好评估。抱歉暂停了进一步完善实现方案,目前先保持基本功能是ok。

近期也尝试wujie微前端框架发现能满足我当前的所有诉求,所以有了本篇的文章内容,前两篇文章的功能和问题在本文中都已支持,选择wujie原因是支持以下两个功能:

  • 天然支持保活模式alive=true,与vben中route中Keeplive参数绑定,能支持状态保持的配置。
  • wujie实现逻辑是iframe框架模式,对子应改造较小,如果不要支持主应用传参子应用可以不用改造或少量改造。

下面分步实施集成功能:

二、主应用调整

1.安装wujie和wujie-vue3

# 安装wujie
pnpm i wujie
# 安装wujie-vue3
pnpm i wujie-vue3

2. 清除沙箱数据实现

主应用src下添加wujie文件夹并添加index.ts文件,两个函数实现功能是清理沙箱缓存数据,保证在”退出登录重新打开“样式不会异常,refreshApp函数为后续单个页签关闭提供备用支持。 index.ts,文件内容如下:

interface HTMLIframeElementWithContentWindow extends HTMLIFrameElement {
  contentWindow: Window;
}

// refreshApp 主应用可以通过下述方法,主动清除指定子应用的沙箱缓存
const refreshApp = (name = '') => {
  if (!name) {
    console.error('refreshApp方法必须传入子应用的name属性');
    return;
  }

  // 这里的window应该是顶级窗口,也就是主应用的window
  const SUB_FRAME = window.document.querySelector(
    `iframe[name=${name}]`,
  ) as HTMLIframeElementWithContentWindow;

  if (!SUB_FRAME) {
    console.warn(`未找到${name}子应用,跳过刷新`);
    return;
  }

  const SUB_WINDOW = SUB_FRAME.contentWindow;
  const SUB_IDMAP = SUB_WINDOW.__WUJIE?.inject?.idToSandboxMap; // 沙箱Map对象
  SUB_IDMAP.clear();
};

// 主应用中清除所有已激活的子应用沙箱缓存
const refreshAllApp = () => {
  // 找到所有无界子应用的iframe
  const ALL_SUB_IFRAME = window.document.querySelectorAll(
    'iframe[data-wujie-flag]',
  );

  if (ALL_SUB_IFRAME.length === 0) {
    console.warn('未找到任何子应用,跳过刷新');
    return;
  }

  // 拿到这些iframe里面的contentWindow
  const ALL_SUB_WINDOW = [...ALL_SUB_IFRAME].map(
    (v) => (v as HTMLIframeElementWithContentWindow).contentWindow,
  );

  // 依次执行清除
  ALL_SUB_WINDOW.forEach((v) => v.__WUJIE?.inject?.idToSandboxMap?.clear());
};

export { refreshAllApp, refreshApp };

主应用/src/layouts/basic.vue 程序主界面,在头部引入上述文件并在相应位置调用清除沙箱方法

# 引用
import { refreshAllApp } from '#/wujie/index';

# 退出时清理
// logout
async function handleLogout() {
  await authStore.logout(false);
  refreshAllApp();
}

3. 添加微服务通用页面wujie.vue

在主应用 /apps/web-caipu/src/views/_core下添加wujie.vue页面,页面内容如:

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

import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';

import WujieVue from 'wujie-vue3';

const useStore = useUserStore();
const accessStore = useAccessStore();
const route = useRoute();

// props通信
const props = ref({
  userinfo: useStore.userInfo,
  token: accessStore.accessToken,
  preferences,
});
// 加时缀是强制刷新
const appUrl = ref(`http://localhost:5667/app${route.path}?t=${Date.now()}`);
const keepLive = route.meta?.keepAlive;
</script>
<template>
  <div class="sub-app-container">
    <WujieVue
      width="100%"
      height="100%"
      :name="appUrl"
      :url="appUrl"
      :alive="keepLive"
      :props="props"
    />
  </div>
</template>
<style scoped>
.sub-app-container {
  width: 100%;
  height: 100%;
  background: white;
}
</style>

<style scoped>
.sub-app-container {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: white;
  border-radius: 8px;
}
</style>

聪明的你,一定知道实现的逻辑。其中子应用的地址测试写localhost:5667,后面会集成配置文件中,至此主应用改造完成。

三、子应用改造

子应用基本不用改,只要改/Users/wgh/code/caipu-vben-admin/apps-micro/web-antd/src/bootstrap.ts文件即可

image.png 在49行添加如下代码,代码不用解释,之前一样的实现逻辑。


 // 初使化存储之后赋值,避免路由判断跳转到登录页
  if (window.__POWERED_BY_WUJIE__) {
    // props 接收
    const props = window.$wujie?.props; // {data: xxx, methods: xxx}
    const useStore = useUserStore();
    const accessStore = useAccessStore();
    useStore.setUserInfo(props.userInfo);
    accessStore.setAccessToken(props.token);
    updatePreferences(props.preferences);
    // window.$wujie?.bus.$on('wujie-theme-update', (theme: any) => {
    //   alert('wujie-theme-update');
    //   updatePreferences(theme);
    // });
    window.addEventListener('wujie-theme-update', (theme: any) => {
      updatePreferences(theme.detail);
    });
  }

四。新增路由配置

在主应用路由中配置子应用一个测试路由 /app/basic/test,

image.png

为测试在子应用状态保持,我在页面中添加一个测试文本框 ,测试内容不会随着切tab页签而重新加载,浏览器的前进后退也不会出错。

image.png

上述功能已集成在前端程序里,如果我的文章对你有帮助,感谢给我点个🌟

Vue2跨组件通信方案:全局事件总线与Vuex的灵活结合

Vue2跨组件通信方案:全局事件总线与Vuex的灵活结合

前端高频面试/开发考点!一文吃透Vue2跨组件通信核心,Bus+Vuex结合用法拆解,代码可直接复制复用,新手也能快速避坑,收藏备用~

📋 目录

  • 一、核心前言(为什么需要两种方案结合?)

  • 二、全局事件总线(Bus)详解(3步落地+避坑)

  • 三、Vuex详解(Vue2状态管理核心,5大模块+4步实战)

  • 四、Bus与Vuex灵活结合(实战场景+核心优势)

  • 五、高频避坑指南(面试常考)

  • 六、核心总结(快速回顾重点)


一、核心前言

Vue2开发中,跨组件通信是绕不开的高频需求,不同组件层级(父子、兄弟、隔代、无关联)对应不同解决方案,单一方案往往有局限性:

  • props/emit:仅适合父子组件,层级嵌套多时会出现“props drilling”(props穿透),代码冗余;

  • 全局事件总线(Bus):轻量高效,但无状态管理,复杂场景难以维护;

  • Vuex:集中管理状态,适合复杂场景,但配置繁琐,简单通信成本高。

核心原则:简单通信用Bus(轻量高效),复杂状态用Vuex(统一管理),两者灵活结合,可高效解决99%的Vue2跨组件通信需求。


二、全局事件总线(Bus)详解

1. 什么是全局事件总线?

本质:通过Vue实例作为“中间桥梁”,实现任意组件间的事件传递(触发+监听),无需层层传递,轻量无依赖、无需额外安装,是简单跨组件通信的最优解。

适用场景:兄弟组件通信、隔代组件简单通信、无关联组件单次通信(如弹窗关闭、通知提示、页面刷新通知)。

2. 实现步骤(3步落地,代码可直接复制)

步骤1:创建全局Bus实例(main.js配置)
// main.js(Vue2项目)
import Vue from 'vue'
import App from './App.vue'

// 创建全局事件总线,挂载到Vue原型,所有组件可直接访问
Vue.prototype.$Bus = new Vue()

new Vue({
  el: '#app',
  render: h => h(App)
})
步骤2:发送事件(触发方组件)

通过 this.$Bus.$emit('事件名', 传递的数据) 发送事件,支持任意类型数据(对象、数组、基本类型)。

<template>
  <button @click="sendMsg" style="padding: 8px 16px; cursor: pointer;">发送消息给兄弟组件</button>
</template>

<script>
export default {
  methods: {
    sendMsg() {
      // 事件名建议语义化,避免冲突(可加组件前缀,如brother-msg)
      this.$Bus.$emit('brotherMsg', {
        content: 'Hello,兄弟组件!',
        time: new Date().toLocaleString()
      })
    }
  }
}
</script>
步骤3:监听事件(接收方组件)

通过 this.$Bus.$on('事件名', 回调函数) 监听事件,重点:必须在beforeDestroy中销毁监听,避免内存泄漏和事件多次触发。

<template>
  <div class="brother-component">
    <h4>接收兄弟组件消息:</h4>
    <p v-if="msg">{{ msg.content }}({{ msg.time }})</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      msg: null
    }
  },
  mounted() {
    // 监听事件,与发送方事件名保持一致
    this.$Bus.$on('brotherMsg', (data) => {
      this.msg = data
    })
  },
  beforeDestroy() {
    // 销毁监听,避免内存泄漏(必写!面试常考)
    this.$Bus.$off('brotherMsg')
  }
}
</script>

3. Bus核心方法速查(表格清晰记)

方法名 说明 使用示例
$emit 发送事件,传递数据 this.Bus.Bus.emit('name', data)
$on 监听事件,接收数据 this.Bus.Bus.on('name', (data)=>{})
$off 销毁监听,避免泄漏 this.Bus.Bus.off('name')

4. Bus优缺点(辩证看待)

✅ 优点

  • 轻量、简单、无依赖,接入成本极低

  • 无需额外配置,开箱即用

  • 适合简单通信场景,效率高

❌ 缺点

  • 无状态管理,无法追踪数据来源

  • 事件名易冲突,维护成本随项目变大升高

  • 不适合多组件共享、频繁修改的复杂状态


三、Vuex详解(Vue2状态管理核心)

1. 什么是Vuex?

Vue2官方状态管理库,用于集中管理所有组件的共享状态(如用户信息、购物车数据、全局设置),实现组件间状态共享和统一修改,可追踪状态变化,是中大型Vue2项目的首选方案。

适用场景:多组件共享状态、需频繁修改/追踪的复杂状态、全局状态管理(如用户登录状态、主题切换)。

2. Vuex核心概念(5大模块,面试必背)

记牢这5个模块,即可掌握Vuex核心用法,面试高频提问!

  • state:存储全局状态(类似组件的data),唯一数据源,所有组件共享;

  • mutations:修改state的唯一方式(仅支持同步操作),禁止写异步代码;

  • actions:处理异步操作(如接口请求),不能直接修改state,需通过commit调用mutations;

  • getters:对state进行加工处理(类似组件的computed),可缓存结果,避免重复计算;

  • modules:拆分模块(大型项目用),避免state过于臃肿,每个模块可拥有独立的state、mutations等。

3. 使用步骤(4步落地,实战可直接复用)

步骤1:安装Vuex(Vue2专属版本,避坑关键)

Vue2必须安装3.x版本,4.x版本仅适配Vue3,装错会直接报错!

# Vue2项目安装命令(固定版本,避免兼容问题)
npm install vuex@3.6.2 --save
步骤2:创建Vuex实例(src/store/index.js)
// src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

// 安装Vuex插件
Vue.use(Vuex)

// 创建Vuex实例
const store = new Vuex.Store({
  // 存储全局状态
  state: {
    userInfo: null, // 多组件共享:用户信息
    count: 0 // 示例:简单共享计数
  },
  // 同步修改state(仅同步操作)
  mutations: {
    setUserInfo(state, data) {
      state.userInfo = data // 只能通过mutation修改state
    },
    increment(state) {
      state.count++
    }
  },
  // 处理异步操作(如接口请求)
  actions: {
    // 模拟异步获取用户信息(实际项目替换为接口请求)
    getUserInfoAsync({ commit }, data) {
      setTimeout(() => {
        // 异步操作完成后,通过commit调用mutation修改state
        commit('setUserInfo', data)
      }, 1000)
    }
  },
  // 加工state,缓存结果
  getters: {
    // 判断用户是否登录
    isLogin(state) {
      return !!state.userInfo
    },
    // 获取计数的2倍(缓存结果,避免重复计算)
    doubleCount(state) {
      return state.count * 2
    }
  }
})

export default store
步骤3:挂载Vuex到Vue实例(main.js)
// main.js
import Vue from 'vue'
import App from './App.vue'
import store from './store' // 引入store实例
import Vuex from 'vuex'

Vue.use(Vuex)

new Vue({
  el: '#app',
  render: h => h(App),
  store // 挂载后,所有组件可通过this.$store访问Vuex
})
步骤4:组件中使用Vuex(读取/修改状态)
<template>
  <div class="vuex-demo">
    <h4>Vuex状态使用示例</h4>
    <p>当前计数:{{ $store.state.count }}</p>
    <p>计数的2倍:{{ $store.getters.doubleCount }}</p>
    <p>用户是否登录:{{ $store.getters.isLogin ? '已登录' : '未登录' }}</p>
    
    <button @click="addCount" style="margin-right: 10px; padding: 8px 16px;">增加计数</button>
    <button @click="getUserInfo" style="padding: 8px 16px;">模拟登录</button>
  </div>
</template>

<script>
export default {
  methods: {
    // 同步修改state:调用mutation(唯一方式)
    addCount() {
      this.$store.commit('increment')
    },
    // 异步修改state:调用action,由action触发mutation
    getUserInfo() {
      this.$store.dispatch('getUserInfoAsync', {
        username: 'vue2demo',
        age: 22
      })
    }
  }
}
</script>

4. Vuex优缺点(辩证看待)

✅ 优点

  • 集中管理共享状态,可追踪状态变化(调试方便);

  • 规范组件通信,避免数据混乱;

  • 适合复杂场景,维护成本低,扩展性强。

❌ 缺点

  • 配置繁琐,简单通信场景(如单次弹窗)使用成本高;

  • 小型项目无需使用,过度封装会增加冗余。


四、Bus与Vuex的灵活结合(核心重点)

1. 结合原则(实战核心)

记住一句话:简单通信用Bus,复杂状态用Vuex,两者互补,避开单一方案的弊端,提升开发效率。

  • 用Bus的场景:一次性通信、无状态依赖通信(弹窗关闭、兄弟组件单次消息、页面刷新通知);

  • 用Vuex的场景:多组件共享状态、需频繁修改/追踪的复杂状态(用户信息、购物车、全局设置)。

2. 实战结合示例(面试常考场景)

场景:用户登录成功后,用Vuex同步全局用户状态,用Bus通知所有相关组件(导航栏、个人中心)刷新页面。

// 1. 登录组件(触发登录,调用Vuex action + 发送Bus事件)
export default {
  methods: {
    login() {
      // 模拟接口请求登录,获取用户数据
      const userData = { username: 'vue2demo', role: 'admin' }
      // ① 调用Vuex action,同步用户状态到全局(复杂状态管理)
      this.$store.dispatch('getUserInfoAsync', userData)
      // ② 发送Bus事件,通知其他组件刷新(简单一次性通信)
      this.$Bus.$emit('userLoginSuccess', userData)
    }
  }
}

// 2. 导航栏组件(监听Bus事件 + 读取Vuex状态)
export default {
  data() {
    return {
      userInfo: null
    }
  },
  mounted() {
    // 监听Bus事件,接收登录成功通知,局部更新
    this.$Bus.$on('userLoginSuccess', (data) => {
      this.userInfo = data
    })
    // 初始化时,读取Vuex中的全局用户状态
    this.userInfo = this.$store.state.userInfo
  },
  beforeDestroy() {
    // 销毁Bus监听,避免内存泄漏
    this.$Bus.$off('userLoginSuccess')
  }
}

3. 结合优势(为什么要这么用?)

  • ✅ 高效:简单场景无需配置复杂Vuex,降低开发成本;复杂场景用Vuex,保证状态规范;

  • ✅ 灵活:按需选择方案,避免“一刀切”(不用为了简单通信写一堆Vuex配置);

  • ✅ 易维护:状态集中管理(Vuex),单次通信解耦(Bus),代码清晰,后期好维护。


五、高频避坑指南(面试常考,必看!)

这些坑90%的新手都会踩,收藏起来,避免踩坑!

1. Bus避坑(2个核心)

  • 事件名必须语义化,可加组件前缀(如header-close、brother-msg),避免冲突;

  • 必须在beforeDestroy中销毁监听(this.Bus.Bus.off('事件名')),否则会导致内存泄漏、事件多次触发。

2. Vuex避坑(3个核心)

  • Vue2必须安装Vuex@3.x版本,4.x仅适配Vue3,装错会直接报错;

  • mutations只能写同步代码,异步操作(如接口请求)必须放在actions中,否则无法追踪状态变化;

  • 禁止直接修改state(如this.$store.state.count = 1),必须通过mutation修改(面试高频考点)。

3. 结合避坑(2个核心)

  • 不滥用Vuex,简单通信用Bus即可,避免过度封装;

  • Bus仅用于“通知”,不传递大量复杂数据(复杂数据用Vuex存储),避免数据混乱。


六、核心总结

本文核心是「Bus+Vuex灵活结合」,记住以下4点,轻松应对Vue2跨组件通信所有场景:

  1. 全局事件总线(Bus):Vue实例作为桥梁,轻量简单,适合简单通信,重点是销毁监听

  2. Vuex:Vue2官方状态管理库,集中管理共享状态,适合复杂场景,核心是5大模块,禁止直接修改state

  3. 结合逻辑:简单通信用Bus,复杂状态用Vuex,互补使用,提升开发效率和代码可维护性;

  4. 避坑关键:Bus销毁监听、Vuex版本适配、不直接修改state、事件名语义化。

你在Vue2跨组件通信中还遇到过哪些坑?欢迎在评论区留言交流,一起避坑成长~

使用Cursor 完成 Vike + Vue 3 + Element Plus 管理后台 — 从 0 到 1 (实例与文档)

目录

  1. 项目概述
  2. 技术栈
  3. 项目初始化
  4. 目录结构
  5. 核心配置文件
  6. 服务端 — Express 服务器
  7. Vike 页面约定与 Hook 体系
  8. 状态管理 — Pinia
  9. 国际化 — Vue I18n
  10. API 层 — Alova + Axios
  11. Layout 系统
  12. Element Plus 集成(SSR 兼容)
  13. 权限系统
  14. 路由与导航
  15. 业务页面示例
  16. SSR 与 CSR 策略
  17. 关键踩坑与解决方案
  18. 开发与构建命令
  19. 生产部署

1. 项目概述

本项目是一个基于 Vike(前 vite-plugin-ssr)+ Vue 3 的企业级管理后台模板。核心思路是利用 Vike 框架的原生 Hook 体系(+config.ts+guard.ts+data.ts+Layout.vue+onCreateApp.ts)替代传统 Vue Router 的路由守卫和路由配置方式,实现:

  • SSR 首屏渲染 — 首屏数据通过 +data.ts 在服务端预取,直接输出到 HTML
  • 统一权限验证 — 通过 +guard.ts 在 SSR 阶段调用后端权限接口,无权限直接渲染 403 页面
  • 公共 Layout 可定制 — 每个页面可通过 Pinia Store 方法动态修改 Layout 标题、面包屑、顶部按钮等
  • 国际化 — Vue I18n 支持中英文切换,菜单、标题、错误页均支持多语言
  • UI 组件库 — Element Plus 全量引入,SSR 兼容

2. 技术栈

类别 技术 版本 说明
框架 Vue 3 ^3.5 Composition API
元框架 Vike ^0.4.252 SSR / 文件系统路由
Vue 适配 vike-vue ^0.9.10 Vike 的 Vue 3 适配器
UI 组件库 Element Plus ^2.9 管理后台 UI 组件
状态管理 Pinia ^3.0 Vue 3 官方状态管理
国际化 Vue I18n ^11.1 多语言支持
HTTP 请求 Alova + Axios ^3.2 / ^1.9 请求策略库 + HTTP 客户端
服务端 Express 5 ^5.2 Node.js HTTP 服务器
构建工具 Vite 7 ^7.3 开发服务器 + 打包
语言 TypeScript ^5.9 类型安全
CSS 预处理 SCSS ^1.87 样式预处理
代码规范 ESLint + typescript-eslint ^9.39 代码质量保障

3. 项目初始化

3.1 创建项目

# 创建目录
mkdir vike-zyh-test && cd vike-zyh-test

# 初始化 package.json
npm init -y

3.2 安装依赖

运行时依赖:

npm install vue vike vike-vue express compression cookie-parser sirv \
  pinia vue-i18n element-plus alova @alova/adapter-axios axios

开发依赖:

npm install -D vite @vitejs/plugin-vue typescript tsx sass \
  unplugin-auto-import unplugin-vue-components \
  @intlify/unplugin-vue-i18n cross-env \
  eslint @eslint/js eslint-plugin-vue typescript-eslint vue-eslint-parser globals \
  @types/express @types/compression @types/cookie-parser

3.3 设定 package.json Scripts

{
  "type": "module",
   "scripts": {
    "dev": "tsx server/server.ts",
    "build": "vike build",
    "preview": "vike build && cross-env NODE_ENV=production tsx server/server.ts",
    "lint": "eslint .",
    "fix": "eslint . --fix"
  },
}

关键点:开发模式使用 tsx 直接运行 TypeScript 编写的 Express 服务器,而非 vite dev。这允许我们完全掌控服务端中间件、Mock API 和渲染流程。


4. 目录结构

vike-zyh-test/
├── server/                          # Express 服务端
│   └── server.ts                    # 入口:中间件 + Mock API + Vike 渲染
├── src/
│   ├── api/                         # API 层
│   │   ├── alovaInstance.ts         # Alova 实例管理 + apiCreator 统一请求工厂
│   │   ├── createClientApi.ts       # 客户端 Alova 实例创建
│   │   ├── createServerApi.ts       # 服务端 Alova 实例创建(用于 +data.ts / +guard.ts)
│   │   ├── dashboardApi.ts          # Dashboard 业务 API
│   │   └── permissionApi.ts         # 权限业务 API
│   ├── composables/                 # 组合式函数
│   │   ├── useLayout.ts             # Layout 控制接口(setTitle / setBreadcrumbs / setHeaderActions ...)
│   │   ├── usePagination.ts         # 分页逻辑封装
│   │   └── usePermission.ts         # 权限检查(hasPermission)
│   ├── constants/                   # 常量
│   │   ├── constants.ts             # 通用常量(分页默认值、枚举等)
│   │   ├── menu.ts                  # 侧边栏菜单配置
│   │   └── permissionApis.ts        # 权限 API URL 常量(统一管理)
│   ├── directive/                   # 自定义指令
│   │   └── directive.ts             # 指令注册入口(如权限指令 v-permission)
│   ├── i18n/                        # 国际化
│   │   ├── i18n.ts                  # createI18n 工厂函数
│   │   ├── zh-CN.json               # 中文语言包
│   │   └── en-US.json               # 英文语言包
│   ├── layout/                      # Layout 组件
│   │   ├── AppSidebar.vue           # 侧边栏
│   │   └── AppHeader.vue            # 顶部导航栏
│   ├── pages/                       # Vike 文件系统路由 ★
│   │   ├── +config.ts               # 全局页面配置
│   │   ├── +onCreateApp.ts          # Vue App 创建钩子(注册 Pinia/I18n/ElementPlus)
│   │   ├── +guard.ts                # 全局路由守卫(权限验证)
│   │   ├── +Layout.vue              # 全局 Layout
│   │   ├── +Head.vue                # 全局 HTML <head>
│   │   ├── _error/                  # 错误页面(401/403/404/500)
│   │   │   └── +Page.vue
│   │   ├── index/                   # 首页 /
│   │   │   ├── +config.ts
│   │   │   ├── +data.ts             # SSR 数据预取
│   │   │   └── +Page.vue
│   │   └── permission/              # 权限管理模块
│   │       ├── +config.ts
│   │       ├── +data.ts             # SSR 数据预取(权限列表)
│   │       ├── +Page.vue            # 权限列表页
│   │       ├── add/                 # 新增权限 /permission/add
│   │       │   ├── +config.ts
│   │       │   ├── +data.ts         # 空 data,阻止继承父级
│   │       │   └── +Page.vue
│   │       └── @id/                 # 动态路由 /permission/:id
│   │           └── edit/            # 编辑权限 /permission/:id/edit
│   │               ├── +config.ts
│   │               ├── +data.ts     # 空 data,阻止继承父级
│   │               └── +Page.vue
│   ├── scss/                        # 全局样式
│   │   └── common.scss
│   ├── stores/                      # Pinia 状态管理
│   │   ├── global.ts                # 全局状态(env/lang/user)
│   │   └── layout.ts                # 布局状态(title/breadcrumbs/headerActions/sidebar)
│   └── viewComponents/              # 页面级可复用组件
│       └── permission/
│           └── PermissionForm.vue   # 权限表单组件(新增/编辑复用)
├── vite.config.ts                   # Vite 配置
├── tsconfig.json                    # TypeScript 根配置(引用子配置)
├── tsconfig.app.json                # 前端 TS 配置
├── tsconfig.node.json               # Vite 配置用 TS 配置
├── tsconfig.server.json             # 服务端 TS 配置
├── eslint.config.ts                 # ESLint 配置
└── package.json

约定说明pages/ 目录下以 + 开头的文件是 Vike 框架约定文件,分别承担配置、数据预取、守卫、布局、渲染等职责。@id 目录名表示动态路由参数。_error 为 Vike 约定的错误页面目录。


5. 核心配置文件

5.1 package.json

{
  "type": "module",
  "imports": {
    "#*": "./*",
    "#server/*": "./server/*"
  }
}
  • "type": "module" — 启用 ESM
  • "imports" — Node.js 原生子路径导入映射,配合 tsconfig.jsonpaths 实现统一的 # 前缀路径别名

5.2 vite.config.ts

import { fileURLToPath, URL } from 'node:url';
import { readdir } from 'node:fs/promises';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vike from 'vike/plugin';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';

// 自动扫描 src/ 下的子目录,生成路径别名
const srcSubDirs = (
  await readdir(new URL('./src', import.meta.url), { withFileTypes: true })
)
  .filter((d) => d.isDirectory())
  .map(({ name }) => name);

export default defineConfig({
  plugins: [
    vue(),
    vike(),
    AutoImport({
      resolvers: [ElementPlusResolver({ importStyle: false })],
    }),
    Components({
      resolvers: [ElementPlusResolver({ importStyle: false })],
    }),
    VueI18nPlugin({ ssr: true, strictMessage: false }),
  ],
  resolve: {
    alias: {
      '#': fileURLToPath(new URL('./', import.meta.url)),
      '#src': fileURLToPath(new URL('./src', import.meta.url)),
      '#server': fileURLToPath(new URL('./server', import.meta.url)),
      // 自动生成: #api, #composables, #stores, #i18n, #layout, #pages ...
      ...Object.fromEntries(
        srcSubDirs.map((name) => [
          `#${name}`,
          fileURLToPath(new URL(`./src/${name}`, import.meta.url)),
        ]),
      ),
    },
  },
  build: { target: 'es2022' },
});

关键设计点:

配置项 说明
vike() 启用 Vike 插件,提供 SSR + 文件系统路由
ElementPlusResolver({ importStyle: false }) 禁用 样式自动导入,避免 SSR 中加载 CSS 文件报错。样式改为在 +Layout.vue 中手动 import 'element-plus/dist/index.css'
VueI18nPlugin({ ssr: true }) 开启 i18n 的 SSR 优化,编译时处理 <i18n>
路径别名自动扫描 自动读取 src/ 子目录,无需手动逐个配置别名

5.3 TypeScript 配置

项目采用三配置策略

文件 作用 module
tsconfig.app.json 前端源码 (src/) ES2022 / Bundler
tsconfig.node.json Vite 配置文件 ES2022 / Bundler
tsconfig.server.json 服务端代码 (server/) Node16 / Node16

tsconfig.app.json 中配置了所有 # 前缀的路径映射:

{
  "compilerOptions": {
    "paths": {
      "#*": ["./*"],
      "#src/*": ["./src/*"],
      "#api/*": ["./src/api/*"],
      "#stores/*": ["./src/stores/*"],
      "#i18n/*": ["./src/i18n/*"],
      "#layout/*": ["./src/layout/*"],
      "#composables/*": ["./src/composables/*"],
      "#constants/*": ["./src/constants/*"],
      "#directive/*": ["./src/directive/*"],
      "#viewComponents/*": ["./src/viewComponents/*"],
      "#server/*": ["./server/*"]
    }
  }
}

5.4 ESLint 配置

使用 ESLint 9 Flat Config,集成 typescript-eslinteslint-plugin-vue

// eslint.config.ts
import eslint from '@eslint/js';
import pluginVue from 'eslint-plugin-vue';
import tseslint from 'typescript-eslint';
import vueParser from 'vue-eslint-parser';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
  // Vue 文件使用 vue-eslint-parser 嵌套 typescript parser
  {
    files: ['**/*.vue'],
    languageOptions: {
      parser: vueParser,
      parserOptions: { parser: tseslint.parser },
    },
  },
  ...pluginVue.configs['flat/recommended'],
);

6. 服务端 — Express 服务器

server/server.ts 是项目入口,使用 Express 5 搭建 HTTP 服务器:

import express from 'express';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import { renderPage, createDevMiddleware } from 'vike/server';

async function startServer() {
  const app = express();

  // 1. 基础中间件
  app.use(compression());       // Gzip 压缩
  app.use(cookieParser());      // Cookie 解析
  app.disable('x-powered-by');  // 隐藏 Express 标识

  // 2. 静态文件 / Vite 开发中间件
  if (isProd) {
    app.use(sirv('dist/client'));  // 生产环境:静态文件
  } else {
    const { devMiddleware } = await createDevMiddleware({ root });
    app.use(devMiddleware);        // 开发环境:Vite HMR
  }

  // 3. Mock API(开发阶段可替换为真实后端代理)
  app.use(express.json());
  app.get('/api/v1/dashboard/stats', ...);
  app.get('/api/v1/permissions', ...);
  app.post('/api/v1/permission/check', ...);

  // 4. Vike 页面渲染 — 所有未匹配的 GET 请求
  app.get('/{*path}', async (req, res, next) => {
    const pageContext = await renderPage({
      urlOriginal: req.originalUrl,
      headersOriginal: req.headers,
      cookies: req.cookies,
    });

    if (!pageContext.httpResponse) return next();

    const { body, statusCode, headers } = pageContext.httpResponse;
    headers.forEach(([name, value]) => res.setHeader(name, value));
    res.status(statusCode).send(body);
  });

  app.listen(3000);
}

重点说明:

  1. Express 5 路由语法app.get('/{*path}', ...) — Express 5 使用命名通配符,不再支持 app.get('*', ...)
  2. pageContext 初始化headersOriginalcookies 被传入 pageContext,供 +guard.ts+data.ts 中的 SSR API 调用使用(转发原始请求头实现登录态传递)
  3. Mock API 位于 Vike 渲染之前:确保 API 请求不会被 Vike 拦截

7. Vike 页面约定与 Hook 体系

Vike 的核心理念:通过 + 前缀文件约定替代路由配置。每个约定文件承担特定职责,按以下顺序执行:

请求进入 → +guard.ts(权限验证)→ +data.ts(数据预取)→ +Page.vue(页面渲染)
                                                          ↑
                                              +Layout.vue 包裹
                                              +Head.vue 注入 <head>

7.1 +config.ts — 全局/页面级配置

全局配置 src/pages/+config.ts

import vikeVue from 'vike-vue/config';
import type { Config } from 'vike/types';

export default {
  extends: [vikeVue],   // 继承 vike-vue 默认行为
  title: 'Admin',
  passToClient: ['user', 'locale', 'permissionResult', 'routeName'],
  meta: {
    permissionUrls: {
      env: { server: true, client: true },  // 自定义配置项,服务端和客户端均可访问
    },
  },
} satisfies Config;
  • passToClient — 指定哪些 pageContext 属性传递到客户端(SSR → CSR 数据桥接)
  • meta.permissionUrls — 声明自定义页面配置项,用于权限验证

页面级配置 src/pages/permission/+config.ts

import { PERMISSION_APIS } from '../../constants/permissionApis';

export default {
  title: '权限列表',
  permissionUrls: [
    PERMISSION_APIS.LIST,
    PERMISSION_APIS.CREATE,
    PERMISSION_APIS.UPDATE,
    PERMISSION_APIS.DELETE,
  ],
};

每个页面的 +config.ts 中的 permissionUrls 会被 +guard.ts 读取,用于权限验证。权限 URL 常量统一定义在 src/constants/permissionApis.ts 中。

7.2 +onCreateApp.ts — Vue 应用创建钩子

每次渲染(SSR 和 CSR)都会执行此钩子,用于注册全局插件和指令:

import type { OnCreateAppSync } from 'vike-vue/types';
import { createPinia } from 'pinia';
import { ID_INJECTION_KEY, ZINDEX_INJECTION_KEY } from 'element-plus';
import { createI18n } from '#i18n/i18n';
import directives from '#directive/directive';

const onCreateApp: OnCreateAppSync = (pageContext) => {
  const { app } = pageContext;

  // 1. Pinia 状态管理
  app.use(createPinia());

  // 2. Vue I18n 国际化
  app.use(createI18n());

  // 3. Element Plus SSR 兼容 — 必须 provide ID 和 ZIndex
  app.provide(ID_INJECTION_KEY, { prefix: 1024, current: 0 });
  app.provide(ZINDEX_INJECTION_KEY, { current: 0 });

  // 4. 自定义指令
  Object.entries(directives).forEach(([name, directive]) => {
    app.directive(name, directive);
  });
};

export default onCreateApp;

7.3 +Layout.vue — 全局布局

公共 Layout 包裹所有页面,集成侧边栏、顶部导航、Element Plus 配置提供者:

<template>
  <el-config-provider :locale="elementLocale">
    <div class="app-layout">
      <aside v-if="layoutStore.showSidebar" :class="['app-sidebar', { collapsed: layoutStore.sidebarCollapsed }]">
        <AppSidebar :menus="defaultMenus" :collapsed="layoutStore.sidebarCollapsed" />
      </aside>
      <div class="app-main">
        <AppHeader
          v-if="layoutStore.showHeader"
          :breadcrumbs="layoutStore.breadcrumbs"
          :header-actions="layoutStore.headerActions"
          @toggle-sidebar="layoutStore.toggleSidebar()"
        />
        <main class="app-content">
          <slot />  <!-- 页面内容插入点 -->
        </main>
      </div>
    </div>
  </el-config-provider>
</template>

<script lang="ts" setup>
import 'element-plus/dist/index.css';   // 手动引入样式(SSR 兼容)
import '#scss/common.scss';

// ...组件引入与状态管理
</script>

7.4 +Head.vue — 全局 HTML Head

<template>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
</template>

7.5 +guard.ts — 路由守卫(权限验证)

核心权限验证机制,在 SSR 阶段拦截请求:

import type { GuardAsync } from 'vike/types';
import { render } from 'vike/abort';

const guard: GuardAsync = async (pageContext) => {
  const permissionUrls = (pageContext.config as any).permissionUrls;

  // 没有配置权限 URL 的页面,直接放行
  if (!permissionUrls || permissionUrls.length === 0) return;

  // SSR 时调用后台权限验证接口
  if (typeof window === 'undefined') {
    try {
      const { createDefaultAPI } = await import('#api/createServerApi');
      const port = process.env.PORT || 3000;
      const alova = createDefaultAPI({
        baseURL: `http://localhost:${port}/api/v1`,
        headers: (pageContext as any).headersOriginal,  // 转发原始请求头
      });

      const result = await alova.Post('/permission/check', {
        urls: permissionUrls,
        pagePath: pageContext.urlPathname,
      });

      if (!result?.data?.allowed) {
        throw render(403);  // 渲染 403 错误页
      }

      // 权限结果存入 pageContext,传到客户端
      (pageContext as any).permissionResult = result.data;
    } catch (error) {
      if ((error as any)?.isAbort) throw error;  // 已是 abort 直接抛出
      throw render(403);  // 异常也视为无权限
    }
  }
};

7.6 +data.ts — SSR 数据预取

在服务端获取数据,通过 useData() 在页面组件中使用:

// src/pages/index/+data.ts
import type { PageContextServer } from 'vike/types';
import { createDefaultAPI } from '#api/createServerApi';

const SSR_API_BASE = `http://localhost:${process.env.PORT || 3000}/api/v1`;

export type Data = DashboardStats;

export async function data(_pageContext: PageContextServer): Promise<Data> {
  const alova = createDefaultAPI({
    baseURL: SSR_API_BASE,
    headers: (_pageContext as any).headersOriginal,
  });

  const res = await alova.Get('/dashboard/stats');
  return res.data;
}

注意+data.ts 的继承问题 — 子路由会继承父目录的 +data.ts。如果子页面不需要父级数据,需要创建空的 +data.ts 来阻止继承:

// src/pages/permission/add/+data.ts
export type Data = Record<string, never>;
export async function data() { return {}; }

7.7 +Page.vue — 页面组件

每个目录下的 +Page.vue 即该路由对应的页面组件。通过 useData() 获取 SSR 预取数据:

<script lang="ts" setup>
import { useData } from 'vike-vue/useData';
import type { Data } from './+data';

const data = useData<Data>();  // 类型安全地获取 SSR 数据
</script>

7.8 _error/+Page.vue — 错误页面

统一的错误页面,支持 401/403/404/500:

<script lang="ts" setup>
import { usePageContext } from 'vike-vue/usePageContext';

const pageContext = usePageContext();

const errorCode = computed(() => {
  return pageContext.is404 ? 404 : (pageContext.abortStatusCode || 500);
});
</script>

+guard.tsthrow render(403) 时,Vike 会自动渲染 _error/+Page.vue 并传递 abortStatusCode: 403


8. 状态管理 — Pinia

8.1 全局状态 (global.ts)

// src/stores/global.ts
import { defineStore } from 'pinia';

export const useGlobalStore = defineStore('global', {
  state: () => ({
    env: '',
    lang: 'zh-CN',
    user: null as null | { name: string; role: string },
  }),
  actions: {
    updateEnv(env: string) { this.env = env; },
    updateLang(lang: string) { this.lang = lang; },
    updateUser(user: { name: string; role: string } | null) { this.user = user; },
  },
});

8.2 布局状态 (layout.ts)

// src/stores/layout.ts
export const useLayoutStore = defineStore('layout', {
  state: () => ({
    title: '',
    breadcrumbs: [] as BreadcrumbItem[],
    sidebarMenus: [] as MenuItem[],
    showSidebar: true,
    showHeader: true,
    sidebarCollapsed: false,
    headerActions: [] as HeaderAction[],
  }),
  actions: {
    setTitle(title: string) { this.title = title; },
    setHeaderActions(actions: HeaderAction[]) { this.headerActions = actions; },
    clearHeaderActions() { this.headerActions = []; },
    setBreadcrumbs(items: BreadcrumbItem[]) { this.breadcrumbs = items; },
    toggleSidebar() { this.sidebarCollapsed = !this.sidebarCollapsed; },
    resetLayout() {
      this.title = '';
      this.breadcrumbs = [];
      this.headerActions = [];
      this.showSidebar = true;
      this.showHeader = true;
    },
  },
});

类型定义:

export interface BreadcrumbItem {
  label: string;
  path?: string;
}

export interface MenuItem {
  label: string;
  path: string;
  icon?: string;
  children?: MenuItem[];
}

export interface HeaderAction {
  key: string;
  label: string;
  icon?: string;
  type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'default';
  handler: () => void;
}

9. 国际化 — Vue I18n

9.1 创建 I18n 实例

// src/i18n/i18n.ts
import { createI18n as _createI18n } from 'vue-i18n';
import zhCN from '#i18n/zh-CN.json';
import enUS from '#i18n/en-US.json';

export const LANGUAGE = {
  ZH_CN: 'zh-CN',
  EN_US: 'en-US',
} as const;

export function createI18n() {
  return _createI18n({
    legacy: false,          // 使用 Composition API
    locale: LANGUAGE.ZH_CN, // 默认中文
    fallbackLocale: LANGUAGE.ZH_CN,
    messages: {
      [LANGUAGE.ZH_CN]: zhCN,
      [LANGUAGE.EN_US]: enUS,
    },
  });
}

9.2 语言包结构

// zh-CN.json
{
  "app": { "title": "管理后台" },
  "error": {
    "unauthorized": "登录已过期,请重新登录",
    "forbidden": "暂无权限访问此页面",
    "notFound": "页面不存在",
    "serverError": "服务器内部错误,请稍后重试"
  },
  "menu": {
    "home": "首页",
    "permission": "权限管理",
    "permissionList": "权限列表",
    "permissionAdd": "新增权限"
  },
  "common": { "add": "新增", "edit": "编辑", "delete": "删除", ... },
  "permission": { "name": "权限名称", "code": "权限编码", ... },
  "dashboard": { "totalPermissions": "总权限数", ... }
}

9.3 在组件中使用

<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>

<template>
  <span>{{ t('app.title') }}</span>
  <span>{{ t('menu.home') }}</span>
</template>

9.4 菜单配置与 i18n

菜单的 label 字段使用 i18n key,在渲染时通过 t() 翻译:

// src/constants/menu.ts
export const SIDEBAR_MENUS: MenuItem[] = [
  { label: 'menu.home', path: '/', icon: 'House' },
  {
    label: 'menu.permission', path: '/permission', icon: 'Lock',
    children: [
      { label: 'menu.permissionList', path: '/permission' },
      { label: 'menu.permissionAdd', path: '/permission/add' },
    ],
  },
];

10. API 层 — Alova + Axios

项目使用 Alova 作为请求策略层,底层适配 Axios。分为客户端和服务端两套实例。

10.1 核心实例管理 (alovaInstance.ts)

// src/api/alovaInstance.ts

// API 类型枚举
export const API_TYPE = { DEFAULT: 'default', LOCAL: 'local' } as const;

// 基础 URL 映射
export const API_BASE_URL = {
  [API_TYPE.DEFAULT]: '/api/v1',
  [API_TYPE.LOCAL]: '/local-api',
};

// 统一请求工厂
export function apiCreator(options: ApiOption, data?: any, customInstances?: AlovaInstances) {
  const { method = 'get', type = API_TYPE.DEFAULT, pathVariable, ...restOptions } = options;
  const instance = getAlovaInstance(customInstances, type);

  let { url = '' } = restOptions;
  if (pathVariable) url = templateUrl(url, pathVariable);  // URL 模板变量替换

  const methodName = method.charAt(0).toUpperCase() + method.slice(1);

  if (['Post', 'Put', 'Patch', 'Delete'].includes(methodName)) {
    return instance[methodName](url, data, restOptions);
  }
  return instance[methodName](url, { params: data, ...restOptions });
}

10.2 客户端 API (createClientApi.ts)

import { createAlova } from 'alova';
import VueHook from 'alova/vue';
import { axiosRequestAdapter } from '@alova/adapter-axios';

export function createClientAlova({ baseURL, timeout = 30000 }) {
  return createAlova({
    baseURL,
    timeout,
    cacheFor: null,        // 禁用缓存
    statesHook: VueHook,   // 绑定 Vue 响应式
    requestAdapter: axiosRequestAdapter(),
    responded: {
      onSuccess: async (response) => response.data,  // 自动解包 Axios 响应
      onError: (error) => { throw error; },
    },
  });
}

10.3 服务端 API (createServerApi.ts)

export function createServerAlova({ baseURL, headers, timeout = 30000 }) {
  return createAlova({
    baseURL,
    timeout,
    cacheFor: null,
    statesHook: VueHook,
    requestAdapter: axiosRequestAdapter(),
    beforeRequest(method) {
      // 转发原始请求头(携带 Cookie/Authorization 等)
      if (headers) {
        Object.assign(method.config, {
          headers: { ...method.config.headers, ...headers },
        });
      }
    },
    responded: {
      onSuccess: async (response) => response.data,
      onError: (error) => { throw error; },
    },
  });
}

客户端 vs 服务端的关键差异:服务端实例在 beforeRequest 中转发原始请求头(headersOriginal),用于传递登录态(Cookie、Token)。服务端还需要使用绝对 URLhttp://localhost:3000/api/v1)而非相对路径。

10.4 业务 API 定义

业务 API 通过 apiCreator 统一创建,例如权限 API:

// src/api/permissionApi.ts
import { apiCreator, API_TYPE } from '#api/alovaInstance';

export function fetchPermissionList(params, options?, customInstances?) {
  return apiCreator(
    { ...options, method: 'get', url: '/permissions', type: API_TYPE.DEFAULT },
    params, customInstances,
  );
}

export function createPermission(data, options?, customInstances?) {
  return apiCreator(
    { ...options, method: 'post', url: '/permissions', type: API_TYPE.DEFAULT },
    data, customInstances,
  );
}

11. Layout 系统

11.1 公共布局与页面自定义

设计理念:Layout 是全局公共的,但每个页面可以通过 Pinia Store 暴露的方法来修改布局状态。

+Layout.vue(全局布局)
    ├── AppSidebar(侧边栏 — 读取 layoutStore.sidebarMenus)
    ├── AppHeader(顶部栏 — 读取 layoutStore.breadcrumbs / headerActions)
    └── <slot />(页面内容)
            ↑
    页面在 onMounted 中调用 useLayout() 设置标题、面包屑、按钮等

11.2 useLayout 组合式函数

// src/composables/useLayout.ts
export function useLayout() {
  const layoutStore = useLayoutStore();

  onMounted(() => {
    layoutStore.resetLayout();  // 每次页面挂载时重置布局状态
  });

  return {
    setTitle(title: string) { layoutStore.setTitle(title); },
    setBreadcrumbs(items: BreadcrumbItem[]) { layoutStore.setBreadcrumbs(items); },
    setHeaderActions(actions: HeaderAction[]) { layoutStore.setHeaderActions(actions); },
    setShowSidebar(show: boolean) { layoutStore.setShowSidebar(show); },
    setShowHeader(show: boolean) { layoutStore.setShowHeader(show); },
    toggleSidebar() { layoutStore.toggleSidebar(); },
    clearHeaderActions() { layoutStore.clearHeaderActions(); },
  };
}

页面中使用示例:

<script lang="ts" setup>
import { onMounted } from 'vue';
import { useLayout } from '#composables/useLayout';
import { useI18n } from 'vue-i18n';

const { t } = useI18n();
const layout = useLayout();

onMounted(() => {
  layout.setTitle(t('menu.home'));
  layout.setBreadcrumbs([{ label: t('menu.home') }]);
  layout.setHeaderActions([
    { key: 'refresh', label: '刷新', type: 'primary', handler: () => loadData() },
  ]);
});
</script>

11.3 AppSidebar 组件

<!-- src/layout/AppSidebar.vue -->
<template>
  <div class="sidebar-menu">
    <div class="logo">
      <span class="logo-text">{{ t('app.title') }}</span>
    </div>
    <el-menu :default-active="activePath" :collapse="collapsed" @select="handleSelect">
      <template v-for="item in menus" :key="item.path">
        <el-sub-menu v-if="item.children?.length" :index="item.path">
          <template #title>
            <el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
            <span>{{ t(item.label) }}</span>
          </template>
          <el-menu-item v-for="child in item.children" :key="child.path" :index="child.path">
            {{ t(child.label) }}
          </el-menu-item>
        </el-sub-menu>
        <el-menu-item v-else :index="item.path">
          <el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
          <span>{{ t(item.label) }}</span>
        </el-menu-item>
      </template>
    </el-menu>
  </div>
</template>

<script lang="ts" setup>
import { navigate } from 'vike/client/router';

function handleSelect(index: string) {
  navigate(index);  // 使用 Vike 的 navigate 进行客户端路由跳转
}
</script>

重要:不能使用 Element Plus 的 router prop,因为它依赖 Vue Router。Vike 项目中应使用 @select 事件 + navigate() 手动导航。

11.4 AppHeader 组件

<!-- src/layout/AppHeader.vue -->
<template>
  <div class="app-header">
    <div class="header-left">
      <el-icon class="toggle-btn" @click="emit('toggle-sidebar')">
        <Fold v-if="!collapsed" /><Expand v-else />
      </el-icon>
      <el-breadcrumb separator="/">
        <el-breadcrumb-item v-for="item in breadcrumbs" :key="item.label" :to="item.path">
          {{ item.label }}
        </el-breadcrumb-item>
      </el-breadcrumb>
    </div>
    <div class="header-right">
      <!-- 页面自定义按钮区域 -->
      <el-button v-for="action in headerActions" :key="action.key" :type="action.type" @click="action.handler">
        {{ action.label }}
      </el-button>
      <!-- 用户信息 -->
      <el-dropdown>
        <span class="user-info">
          <el-icon><User /></el-icon> {{ user?.name || '未登录' }}
        </span>
      </el-dropdown>
    </div>
  </div>
</template>

12. Element Plus 集成(SSR 兼容)

在 SSR 项目中集成 Element Plus 需要解决三个问题:

12.1 CSS 加载问题

问题unplugin-vue-components 默认会自动导入组件对应的 CSS 文件,但 SSR 时 Node.js 无法处理 .css 文件。

解决方案

// vite.config.ts
Components({
  resolvers: [ElementPlusResolver({ importStyle: false })],  // 禁用自动导入样式
}),
<!-- +Layout.vue 中手动全量引入 -->
<script setup>
import 'element-plus/dist/index.css';
</script>

12.2 ID 注入问题

问题ElementPlusError: [IdInjection] Looks like you are using server rendering, you must provide a id provider

解决方案

// +onCreateApp.ts
import { ID_INJECTION_KEY, ZINDEX_INJECTION_KEY } from 'element-plus';

app.provide(ID_INJECTION_KEY, { prefix: 1024, current: 0 });
app.provide(ZINDEX_INJECTION_KEY, { current: 0 });

12.3 Locale 国际化

<!-- +Layout.vue -->
<template>
  <el-config-provider :locale="elementLocale">
    <!-- ... -->
  </el-config-provider>
</template>

<script setup>
import zhCN from 'element-plus/es/locale/lang/zh-cn';
import enUS from 'element-plus/es/locale/lang/en';

const elementLocale = computed(() => locale.value === 'en-US' ? enUS : zhCN);
</script>

13. 权限系统

13.1 权限 URL 统一管理

所有需要权限验证的 API URL 统一在 src/constants/permissionApis.ts 中管理:

// src/constants/permissionApis.ts
export const PERMISSION_APIS = {
  /** 查询权限列表 */
  LIST: 'GET /api/v1/permissions',
  /** 新增权限 */
  CREATE: 'POST /api/v1/permissions',
  /** 编辑权限 */
  UPDATE: 'PUT /api/v1/permissions',
  /** 删除权限 */
  DELETE: 'DELETE /api/v1/permissions',
} as const;

注意+config.ts 文件由 vike 的 esbuild 插件编译,不支持 Vite 路径别名(#constants/...)。因此在 +config.ts 中必须使用相对路径导入常量,而在 +Page.vue 中可正常使用 # 别名。

各页面 +config.ts 中按需声明所需的权限 URL:

// src/pages/permission/+config.ts(列表页 — 需要所有操作权限)
import { PERMISSION_APIS } from '../../constants/permissionApis';

export default {
  title: '权限列表',
  permissionUrls: [
    PERMISSION_APIS.LIST,
    PERMISSION_APIS.CREATE,
    PERMISSION_APIS.UPDATE,
    PERMISSION_APIS.DELETE,
  ],
};
// src/pages/permission/add/+config.ts(新增页 — 只需 CREATE 权限)
import { PERMISSION_APIS } from '../../../constants/permissionApis';

export default {
  title: '新增权限',
  permissionUrls: [PERMISSION_APIS.CREATE],
};

13.2 页面级权限 — +guard.ts

流程:

  1. 页面在 +config.ts 中声明 permissionUrls(引用统一常量)
  2. +guard.ts 读取该配置,在 SSR 阶段调用后端 POST /api/v1/permission/check
  3. 后端返回 { allowed: true/false, urlPermissions: { [url]: boolean } }
  4. allowed: falsethrow render(403),整页渲染错误页(如新增权限页无 CREATE 权限)
  5. allowed: true 时将 urlPermissions 写入 pageContext.permissionResult,通过 passToClient 传到客户端

13.3 按钮级权限 — usePermission

通过 usePermission() 组合式函数在组件中检查单个 URL 的权限,控制按钮 disabled 状态:

// src/composables/usePermission.ts
export function usePermission() {
  const pageContext = usePageContext();
  const permissionResult = computed(() => (pageContext as any).permissionResult || {});

  function hasPermission(url: string): boolean {
    return permissionResult.value?.urlPermissions?.[url] ?? true;
  }

  return { permissionResult, hasPermission };
}

列表页使用示例(控制添加/编辑/删除按钮):

<script setup>
import { usePermission } from '#composables/usePermission';
import { PERMISSION_APIS } from '#constants/permissionApis';

const { hasPermission } = usePermission();
const canCreate = hasPermission(PERMISSION_APIS.CREATE);
const canUpdate = hasPermission(PERMISSION_APIS.UPDATE);
const canDelete = hasPermission(PERMISSION_APIS.DELETE);
</script>

<template>
  <el-button type="primary" :disabled="!canCreate" @click="handleAdd">新增</el-button>
  <!-- 表格操作列 -->
  <el-button :disabled="!canUpdate" @click="handleEdit(row)">编辑</el-button>
  <el-button :disabled="!canDelete" @click="handleDelete(row)">删除</el-button>
</template>

编辑页使用示例(通过 canSubmit prop 控制表单保存按钮):

<PermissionForm
  :initial-data="detail"
  :is-sending="isSending"
  :can-submit="canUpdate"
  @submit="submit"
  @cancel="goBack"
/>

PermissionForm.vue 中保存按钮根据 canSubmit 属性禁用:

<el-button type="primary" :loading="isSending" :disabled="canSubmit === false" @click="submit">
  {{ t('common.save') }}
</el-button>

13.4 Mock 权限验证(server/server.ts)

开发阶段通过 Mock 接口模拟权限检查:

// 模拟无权限的 URL 列表
const DENIED_URLS = new Set([
  'POST /api/v1/permissions',   // 新增权限
  'PUT /api/v1/permissions',    // 编辑权限
]);

// pagePath + URL 命中时整页拒绝(403)
const PAGE_BLOCKED_RULES = [
  { pathPattern: /^\/permission\/add$/, url: 'POST /api/v1/permissions' },
];

app.post('/api/v1/permission/check', (req, res) => {
  const { urls = [], pagePath = '' } = req.body;
  const urlPermissions = {};
  urls.forEach((url) => { urlPermissions[url] = !DENIED_URLS.has(url); });

  // 命中 PAGE_BLOCKED_RULES 则整页拒绝
  const allowed = !PAGE_BLOCKED_RULES.some(
    (rule) => rule.pathPattern.test(pagePath) && urls.includes(rule.url) && DENIED_URLS.has(rule.url),
  );

  res.json({ code: 0, data: { allowed, urlPermissions } });
});
  • DENIED_URLS — 控制哪些 URL 返回无权限(按钮 disabled)
  • PAGE_BLOCKED_RULES — 当特定页面路径命中被拒绝的 URL 时,整页返回 403

13.5 权限流程图

用户请求页面
    │
    ▼
+guard.ts 读取 +config.ts 中的 permissionUrls(引用 PERMISSION_APIS 常量)
    │
    ├── 未配置 → 直接放行
    │
    └── 已配置 → SSR 调用 POST /api/v1/permission/check { urls, pagePath }
                    │
                    ├── allowed: false → throw render(403) → 渲染 _error/+Page.vue
                    │   (如: /permission/add 页面无 CREATE 权限 → 整页 403)
                    │
                    └── allowed: true → permissionResult 存入 pageContext
                            │
                            └── 组件中通过 usePermission().hasPermission(url) 判断
                                    │
                                    ├── true  → 按钮正常可用
                                    └── false → 按钮 disabled
                                        (如: 编辑页无 UPDATE 权限 → 保存按钮禁用)

14. 路由与导航

14.1 文件系统路由

Vike 根据 src/pages/ 目录结构自动生成路由:

目录结构 路由路径 说明
pages/index/+Page.vue / 首页
pages/permission/+Page.vue /permission 权限列表
pages/permission/add/+Page.vue /permission/add 新增权限
pages/permission/@id/edit/+Page.vue /permission/:id/edit 编辑权限(动态路由)
pages/_error/+Page.vue 错误页面 401/403/404/500

@id 是 Vike 的动态路由语法,等效于 Vue Router 的 :id。通过 pageContext.routeParams.id 获取。

14.2 客户端导航

Vike 提供 navigate 函数实现客户端路由跳转(无刷新):

import { navigate } from 'vike/client/router';

// 跳转到指定页面
navigate('/permission');

// 跳转并替换历史记录
navigate('/permission', { overwriteLastHistoryEntry: true });

+config.ts 中已设置 clientRouting: true(由 vike-vue 默认配置),启用客户端路由。


15. 业务页面示例

15.1 Dashboard 首页

文件src/pages/index/

文件 作用
+config.ts 配置标题 '首页'
+data.ts SSR 调用 /api/v1/dashboard/stats 预取统计数据
+Page.vue 通过 useData() 获取数据,展示统计卡片和操作日志表格
<script setup>
const data = useData<Data>();  // SSR 预取的数据,无需 onMounted 加载
const statCards = computed(() => [
  { key: 'total', label: t('dashboard.totalPermissions'), value: data.totalPermissions },
  // ...
]);
</script>

15.2 权限列表页

文件src/pages/permission/

文件 作用
+config.ts 配置标题 + permissionUrls(启用权限验证)
+data.ts SSR 预取第一页权限列表
+Page.vue 展示列表 + 搜索 + 分页

SSR + CSR 混合:首页数据通过 SSR 预取,后续翻页/搜索通过客户端 Alova 调用。

15.3 新增权限页

文件src/pages/permission/add/

文件 作用
+config.ts 配置标题 + permissionUrls
+data.ts 空 data 文件(阻止继承父级的 +data.ts)
+Page.vue 使用 PermissionForm 组件

关键:必须创建空的 +data.ts,否则会继承 permission/+data.ts 的数据加载逻辑,导致不需要的 API 调用甚至报错。

15.4 编辑权限页

文件src/pages/permission/@id/edit/

与新增页类似,额外通过 pageContext.routeParams.id 获取路由参数,在 onMounted 中加载详情数据:

<script setup>
const pageContext = usePageContext();
const routeParams = pageContext.routeParams as { id: string };

onMounted(() => {
  fetchDetail(routeParams.id);
});
</script>

15.5 可复用组件 — PermissionForm

src/viewComponents/permission/PermissionForm.vue 同时服务于新增和编辑页面:

<script setup>
const props = defineProps<{
  initialData?: Record<string, any>;  // 编辑时传入已有数据
  isSending?: boolean;                // 提交中状态
  canSubmit?: boolean;                // 是否有提交权限(false 时禁用保存按钮)
}>();

const emit = defineEmits<{
  submit: [data: Record<string, any>];
  cancel: [];
}>();

// 表单验证规则
const rules: FormRules = {
  name: [{ required: true, message: '请输入权限名称', trigger: 'blur' }],
  code: [{ required: true, message: '请输入权限编码', trigger: 'blur' }],
  type: [{ required: true, message: '请选择权限类型', trigger: 'change' }],
};
</script>

16. SSR 与 CSR 策略

场景 策略 实现方式
首屏数据 SSR +data.tsuseData()
权限验证 SSR +guard.tsthrow render(403)
翻页/搜索 CSR 组件内直接使用客户端 Alova
表单提交 CSR 组件内调用 API 后 navigate()
页面跳转 CSR navigate() 客户端路由
初始页面加载 SSR Express → renderPage() → HTML

数据流:

SSR 阶段:
  Express → renderPage() → +guard.ts → +data.ts → +Layout.vue + +Page.vue → HTML

CSR 阶段 (客户端路由):
  navigate() → +guard.ts (client) → +data.ts → 组件更新

17. 关键踩坑与解决方案

17.1 Express 5 路由语法变更

问题app.get('*', ...) 报错 Missing parameter name

原因:Express 5 使用新版 path-to-regexp,不再支持裸通配符

解决:改为命名通配符 app.get('/{*path}', ...)

17.2 Element Plus CSS SSR 加载失败

问题TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".css"

原因:Node.js SSR 环境无法处理 CSS 文件

解决

  • ElementPlusResolver({ importStyle: false }) 禁用自动导入样式
  • +Layout.vueimport 'element-plus/dist/index.css'(Vite 会正确处理)

17.3 Element Plus SSR ID/ZIndex 注入

问题:Hydration 失败,控制台报 IdInjectionZIndexInjection 错误

解决:在 +onCreateApp.tsapp.provide(ID_INJECTION_KEY, ...)app.provide(ZINDEX_INJECTION_KEY, ...)

17.4 服务端 API 调用使用相对 URL

问题+data.ts+guard.ts 中使用 /api/v1/xxx 相对路径在 SSR 中无法工作

原因:Node.js 中没有浏览器的 location.origin,相对 URL 无法解析

解决:SSR 中使用绝对 URL http://localhost:${process.env.PORT || 3000}/api/v1

17.5 +data.ts 的继承问题

问题/permission/add 页面继承了 /permission/+data.ts 的数据加载,导致不必要的 API 调用

原因:Vike 的 +data.ts 会沿目录树向上继承

解决:在子目录创建空的 +data.ts

export type Data = Record<string, never>;
export async function data() { return {}; }

17.6 El-Menu 的 router prop 不兼容 Vike

问题:侧边栏菜单点击无反应或报错

原因:Element Plus 的 el-menu router prop 依赖 Vue Router,Vike 项目不使用 Vue Router

解决:移除 router prop,使用 @select 事件 + navigate():

<el-menu @select="handleSelect">
  <!-- ... -->
</el-menu>

<script setup>
import { navigate } from 'vike/client/router';
function handleSelect(index: string) {
  navigate(index);
}
</script>

17.7 process.env 在客户端不可用

问题ReferenceError: process is not defined

原因+guard.ts 在客户端也会执行,但 process.env 仅在 Node.js 中可用

解决:将 process.env 访问放在 if (typeof window === 'undefined') 分支内


18. 开发与构建命令

# 开发(启动 Express + Vite HMR)
npm run dev

# 构建(生成 dist/client + dist/server)
npm run build

# 生产预览
npm run preview

# 代码检查
npm run lint

# 自动修复
npm run fix

开发环境tsx server/server.ts → Express 启动 → createDevMiddleware 注入 Vite HMR → 访问 http://localhost:3000

生产构建vike build → 输出 dist/client(静态资源)+ dist/server(SSR Bundle)


19. 生产部署

19.1 构建产物结构

执行 npm run build(即 vike build)后生成 dist/ 目录:

dist/
├── assets.json                    # 资源映射文件(Vike 内部使用)
├── client/                        # 静态资源(浏览器端)
│   └── assets/
│       ├── chunks/                # JS 代码分割块
│       ├── entries/               # 各页面入口 JS
│       └── static/               # CSS 文件
└── server/                        # SSR 服务端代码
    ├── entry.mjs                  # SSR 入口(Vike renderPage 用)
    ├── entries/                   # 各页面的 SSR 渲染逻辑
    ├── chunks/                    # 服务端公共模块
    └── package.json               # { "type": "module" }

19.2 部署方式

本项目使用 Express 作为生产服务器server/server.ts 同时处理静态文件托管和 SSR 渲染。部署步骤:

1. 构建

npm run build

2. 部署所需文件

将以下文件/目录上传到服务器:

dist/                # 构建产物(client + server)
server/server.ts     # Express 服务器入口
package.json         # 依赖声明
node_modules/        # 或在服务器上 npm install

3. 启动服务

# 方式一:直接用 tsx 运行 TypeScript(需安装 tsx)
cross-env NODE_ENV=production tsx server/server.ts

# 方式二:用 PM2 管理进程(推荐)
pm2 start "cross-env NODE_ENV=production tsx server/server.ts" --name vike-admin

# 自定义端口
cross-env NODE_ENV=production PORT=8080 tsx server/server.ts

运行原理: server/server.ts 中根据 NODE_ENV 自动切换行为:

if (isProd) {
  // 生产环境:sirv 托管 dist/client 静态文件
  const sirv = (await import('sirv')).default;
  app.use(sirv(`${root}/dist/client`));
} else {
  // 开发环境:Vite HMR 开发中间件
  const { devMiddleware } = await createDevMiddleware({ root });
  app.use(devMiddleware);
}

Vike 的 renderPage() 在生产环境会自动加载 dist/server/entry.mjs 进行 SSR 渲染。

19.3 Nginx 反向代理(可选)

如果需要通过 Nginx 暴露服务:

server {
    listen 80;
    server_name your-domain.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

19.4 Docker 部署(可选)

FROM node:20-alpine
WORKDIR /app

COPY package.json yarn.lock ./
RUN yarn install --production=false

COPY . .
RUN yarn build

ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000

CMD ["npx", "tsx", "server/server.ts"]
docker build -t vike-admin .
docker run -d -p 3000:3000 vike-admin

19.5 注意事项

事项 说明
NODE_ENV 必须设为 production,否则会尝试启动 Vite 开发中间件
Mock API 生产环境应替换为真实后端 API 代理,移除 Mock 路由
tsx 生产环境仍需 tsx 来运行 TypeScript 的 server.ts,也可预编译为 JS
端口 默认 3000,可通过 PORT 环境变量修改
dist/ 路径 server.ts 通过 __dirname + '/.. 定位 dist,部署时保持目录相对关系

本文档对应项目版本:2026-02-12 · Vike 0.4.252 · Vue 3.5 · Element Plus 2.9 · Express 5.2

Vue3文本差异对比器实现方案

Vue3文本差异对比器实现方案

本文将介绍本项目中 文本差异对比器 (Text Diff Checker) 工具的技术实现细节。该工具基于 Vue 3 框架开发,核心对比逻辑采用原生的 JavaScript 实现,通过动态加载的方式与 Vue 组件进行交互。

在线工具网址:see-tool.com/diff-checke…
工具截图:
在这里插入图片描述

1. 架构设计

为了保证核心算法的独立性和复用性,我们将 Diff 算法逻辑封装在 public/js/diff-checker.js 中,而 Vue 组件 pages/diff-checker.vue 仅负责 UI 交互和数据展示。

  • 数据层 (Core JS): 负责文本的预处理、Diff 算法计算、HTML 渲染字符串生成以及统计信息计算。
  • 视图层 (Vue): 负责用户输入、选项配置、调用核心方法并展示结果。

2. 核心算法实现 (diff-checker.js)

核心逻辑是一个基于 最长公共子序列 (LCS, Longest Common Subsequence) 的 Diff 算法。

2.1 文本预处理与并在

根据用户选择的“对比模式”,我们将输入文本分割成不同的单元:

  • 行模式 (Line): 使用 split('\n') 按换行符分割。
  • 词模式 (Word): 使用 split(/\s+/) 按空白字符分割。
  • 字符模式 (Char): 使用 split('') 逐字符分割。

同时,根据配置选项处理“忽略空格”和“忽略大小写”:

if (ignoreWhitespace) {
    processedText1 = processedText1.replace(/\s+/g, ' ').trim();
    processedText2 = processedText2.replace(/\s+/g, ' ').trim();
}
// 忽略大小写则统一转为小写

2.2 LCS 算法与回溯

使用动态规划构建 DP 表,计算最长公共子序列的长度:

// DP 表构建
for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
        if (arr1[i - 1] === arr2[j - 1]) {
            dp[i][j] = dp[i - 1][j - 1] + 1;
        } else {
            dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
        }
    }
}

构建完成后,通过回溯 (Backtrack) 找出具体的 LCS 路径。

2.3 构建 Diff 结果

根据 LCS 路径,遍历原始序列,确定哪些部分是“新增 (added)”、“删除 (removed)”或“未变 (unchanged)”。

  • 如果当前元素在 LCS 中,标记为 unchanged
  • 如果原序列中有但 LCS 中没有,标记为 removed
  • 如果新序列中有但 LCS 中没有,标记为 added

2.4 结果渲染

为了提高性能,Diff 的结果直接由 JS 生成 HTML 字符串,而不是在 Vue 中使用 v-for 渲染成千上万个 DOM 节点。生成的 HTML 包含了行号、差异标识(+/-)以及高亮样式类。

/* 生成的 HTML 结构示例 */
<div class="diff-line diff-line-removed">
  <span class="diff-line-number">1</span>
  <span class="diff-line-number"></span>
  <span class="mr-2">-</span>
  Content
</div>

3. Vue 组件实现 (diff-checker.vue)

3.1 动态加载脚本

Vue 组件在挂载或需要使用时,通过创建 <script> 标签动态加载核心 JS 文件。为了防止重复加载,我们通过检查 window.DiffChecker 是否存在来判断。

const loadDiffCheckerScript = () => {
  if (window.DiffChecker) return Promise.resolve();
  // 创建 script 标签加载 /js/diff-checker.js
  // 监听 onload 和 onerror 事件
}

3.2 调用对比

当用户点击“开始对比”时,组件收集 leftTextrightText 以及 compareModeignoreWhitespace 等选项,调用核心对象的 compare 方法:

const result = window.DiffChecker.compare(leftText.value, rightText.value, compareMode.value, {
  ignoreWhitespace: ignoreWhitespace.value,
  ignoreCase: ignoreCase.value,
  showLineNumbers: showLineNumbers.value
})

3.3 结果展示

核心方法返回的 result 对象中包含了 diffHtml(差异内容的 HTML)和 statisticsHtml(统计信息的 HTML)。Vue 组件直接使用 v-html 指令将其渲染到页面上:

<div v-if="statisticsHtml" v-html="statisticsHtml"></div>
<div ref="diffOutput" v-html="diffOutputHtml"></div>

通过这种 Vue 处理交互 + 原生 JS 处理计算密集任务的分离模式,我们既保持了前端框架的开发效率,又保证了对比功能的性能与灵活性。

Vue3 组件通信全解析

组件通信是 Vue 开发中绕不开的核心知识点,尤其是 Vue3 组合式 API 普及后,通信方式相比 Vue2 有了不少变化和优化。本文将抛开 TypeScript,用最通俗易懂的方式,带你梳理 Vue3 中所有常用的组件通信方式,从基础的父子通信到复杂的跨层级通信,每一种都配实战示例,新手也能轻松上手。

一、父子组件通信(最基础也最常用)

父子组件通信是日常开发中使用频率最高的场景,Vue3 为这种场景提供了清晰且高效的解决方案。

1. 父传子:Props

Props 是父组件向子组件传递数据的官方标准方式,子组件通过定义 props 接收父组件传递的值。

父组件(Parent.vue)

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <!-- 向子组件传递数据 -->
    <Child 
      :msg="parentMsg" 
      :user-info="userInfo"
      :list="fruitList"
    />
  </div>
</template>

<script setup>
// 引入子组件
import Child from './Child.vue'
import { ref, reactive } from 'vue'

// 定义要传递给子组件的数据
const parentMsg = ref('来自父组件的问候')
const userInfo = reactive({
  name: '张三',
  age: 25
})
const fruitList = ref(['苹果', '香蕉', '橙子'])
</script>

子组件(Child.vue)

<template>
  <div class="child">
    <h4>我是子组件</h4>
    <p>父组件传递的字符串:{{ msg }}</p>
    <p>父组件传递的对象:{{ userInfo.name }} - {{ userInfo.age }}岁</p>
    <p>父组件传递的数组:{{ list.join('、') }}</p>
  </div>
</template>

<script setup>
// 定义props接收父组件数据
const props = defineProps({
  // 字符串类型
  msg: {
    type: String,
    default: '默认值'
  },
  // 对象类型
  userInfo: {
    type: Object,
    default: () => ({}) // 对象/数组默认值必须用函数返回
  },
  // 数组类型
  list: {
    type: Array,
    default: () => []
  }
})

// 在脚本中使用props(组合式API中可直接用props.xxx)
console.log(props.msg)
</script>

2. 子传父:自定义事件(Emits)

子组件通过触发自定义事件,将数据传递给父组件,父组件通过监听事件接收数据。

子组件(Child.vue)

<template>
  <div class="child">
    <h4>我是子组件</h4>
    <button @click="sendToParent">向父组件传递数据</button>
  </div>
</template>

<script setup>
// 声明要触发的自定义事件(可选,但推荐)
const emit = defineEmits(['childMsg', 'updateInfo'])

const sendToParent = () => {
  // 触发事件并传递数据(第一个参数是事件名,后续是要传递的数据)
  emit('childMsg', '来自子组件的消息')
  emit('updateInfo', {
    name: '李四',
    age: 30
  })
}
</script>

父组件(Parent.vue)

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <!-- 监听子组件的自定义事件 -->
    <Child 
      @childMsg="handleChildMsg"
      @updateInfo="handleUpdateInfo"
    />
    <p>子组件传递的消息:{{ childMsg }}</p>
    <p>子组件更新的信息:{{ newUserInfo.name }} - {{ newUserInfo.age }}岁</p>
  </div>
</template>

<script setup>
import Child from './Child.vue'
import { ref, reactive } from 'vue'

const childMsg = ref('')
const newUserInfo = reactive({
  name: '',
  age: 0
})

// 处理子组件的消息
const handleChildMsg = (msg) => {
  childMsg.value = msg
}

// 处理子组件的信息更新
const handleUpdateInfo = (info) => {
  newUserInfo.name = info.name
  newUserInfo.age = info.age
}
</script>

二、跨层级组件通信

当组件嵌套层级较深(比如爷孙组件、跨多级组件),使用 props + emits 会非常繁琐,这时需要更高效的跨层级通信方案。

1. provide /inject(依赖注入)

provide 用于父组件(或祖先组件)提供数据,inject 用于子孙组件注入数据,支持任意层级的组件通信。

祖先组件(GrandParent.vue)

<template>
  <div class="grand-parent">
    <h3>我是祖先组件</h3>
    <Parent />
  </div>
</template>

<script setup>
import Parent from './Parent.vue'
import { ref, reactive, provide } from 'vue'

// 提供基本类型数据
const theme = ref('dark')
provide('theme', theme)

// 提供对象类型数据
const globalConfig = reactive({
  fontSize: '16px',
  color: '#333'
})
provide('globalConfig', globalConfig)

// 提供方法(支持双向通信)
provide('changeTheme', (newTheme) => {
  theme.value = newTheme
})
</script>

孙组件(Child.vue)

<template>
  <div class="child">
    <h4>我是孙组件</h4>
    <p>祖先组件提供的主题:{{ theme }}</p>
    <p>全局配置:{{ globalConfig.fontSize }} / {{ globalConfig.color }}</p>
    <button @click="changeTheme('light')">切换为亮色主题</button>
  </div>
</template>

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

// 注入祖先组件提供的数据(第二个参数是默认值)
const theme = inject('theme', 'light')
const globalConfig = inject('globalConfig', {})
const changeTheme = inject('changeTheme', () => {})
</script>

2. Vuex/Pinia(全局状态管理)

当多个不相关的组件需要共享状态,或者项目规模较大时,推荐使用官方的状态管理库,Vue3 中更推荐 Pinia(比 Vuex 更简洁)。

示例:Pinia 实现全局通信

1. 安装 Pinia

npm install pinia

2. 创建 Pinia 实例(main.js)

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

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

3. 创建 Store(stores/user.js)

import { defineStore } from 'pinia'

// 定义并导出store
export const useUserStore = defineStore('user', {
  // 状态
  state: () => ({
    username: '默认用户名',
    token: ''
  }),
  // 计算属性
  getters: {
    // 处理用户名格式
    formatUsername: (state) => {
      return `【${state.username}】`
    }
  },
  // 方法(修改状态)
  actions: {
    // 更新用户信息
    updateUserInfo(newInfo) {
      this.username = newInfo.username
      this.token = newInfo.token
    },
    // 清空用户信息
    clearUserInfo() {
      this.username = ''
      this.token = ''
    }
  }
})

4. 组件中使用 Store

<template>
  <div>
    <h3>全局状态管理示例</h3>
    <p>用户名:{{ userStore.formatUsername }}</p>
    <p>Token:{{ userStore.token }}</p>
    <button @click="updateUser">更新用户信息</button>
    <button @click="clearUser">清空用户信息</button>
  </div>
</template>

<script setup>
import { useUserStore } from '@/stores/user'

// 获取store实例
const userStore = useUserStore()

// 更新用户信息
const updateUser = () => {
  userStore.updateUserInfo({
    username: '掘金用户',
    token: '123456789'
  })
}

// 清空用户信息
const clearUser = () => {
  userStore.clearUserInfo()
}
</script>

三、其他常用通信方式

1. v-model 双向绑定

Vue3 中 v-model 支持自定义绑定属性,可实现父子组件的双向数据绑定,简化子传父的操作。

子组件(Child.vue)

<template>
  <div class="child">
    <input 
      type="text" 
      :value="modelValue" 
      @input="emit('update:modelValue', $event.target.value)"
    />
    <!-- 支持多个v-model -->
    <input 
      type="number" 
      :value="age" 
      @input="emit('update:age', $event.target.value)"
    />
  </div>
</template>

<script setup>
defineProps(['modelValue', 'age'])
const emit = defineEmits(['update:modelValue', 'update:age'])
</script>

父组件(Parent.vue)

<template>
  <div class="parent">
    <h3>父组件</h3>
    <Child 
      v-model="username"
      v-model:age="userAge"
    />
    <p>用户名:{{ username }}</p>
    <p>年龄:{{ userAge }}</p>
  </div>
</template>

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

const username = ref('')
const userAge = ref(0)
</script>

2. 事件总线(mitt)

Vue3 移除了 Vue2 的 $on/$emit 事件总线,可使用第三方库 mitt 实现任意组件间的通信。

1. 安装 mitt

npm install mitt

2. 创建事件总线(utils/bus.js)

import mitt from 'mitt'
const bus = mitt()
export default bus

3. 组件 A 发送事件

<template>
  <div>
    <button @click="sendMsg">发送消息到组件B</button>
  </div>
</template>

<script setup>
import bus from '@/utils/bus'

const sendMsg = () => {
  // 触发自定义事件并传递数据
  bus.emit('msgEvent', '来自组件A的消息')
}
</script>

4. 组件 B 接收事件

<template>
  <div>
    <p>组件A传递的消息:{{ msg }}</p>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import bus from '@/utils/bus'

const msg = ref('')

// 挂载时监听事件
onMounted(() => {
  bus.on('msgEvent', (data) => {
    msg.value = data
  })
})

// 卸载时移除监听(避免内存泄漏)
onUnmounted(() => {
  bus.off('msgEvent')
})
</script>

四、通信方式选型建议

表格

通信场景 推荐方式
父传子 Props
子传父 自定义事件(Emits)/v-model
爷孙 / 跨层级 provide / inject
全局共享状态 Pinia
任意组件临时通信 mitt 事件总线

总结

  1. Vue3 中父子组件通信优先使用 Props + Emits,v-model 可简化双向绑定场景;
  2. 跨层级通信推荐 provide / inject,全局状态管理首选 Pinia
  3. 临时的任意组件通信可使用 mitt 事件总线,注意及时移除监听避免内存泄漏。

组件通信的核心是 “数据流向清晰”,无论选择哪种方式,都要保证数据的传递路径可追溯,避免滥用全局通信导致代码维护困难。希望本文能帮助你彻底掌握 Vue3 组件通信,少走弯路~

Unaipp 使用 wot UI 实现一个带数字键盘的密码输入框弹窗

最近项目里有个支付输入密码的需求,所以在这之前都是使用一个简单的输入框实现的,但是这样体验不太好。所以,这次就改成了弹窗,尝试达到类似支付宝的弹窗输入密码的形式。

前言

在 Wot UI 中是有密码输入框(wd-password-input)和数字键盘(wd-number-keyboard)两个组件的,但是在文档示例中你会发现,数字键盘是以弹窗的形式覆盖在界面顶层的。如果我们直接使用这个组件,就会出现弹窗盖在弹窗上的奇怪问题。

所以最好的方式,是改写数字键盘组件的全局样式,再将其和密码输入框组合起来,放到新的弹窗中。

防止数字键盘下沉

打开控制台管擦,我们会发现数字键盘实际上也是一个弹窗,而内部会通关组件参数 v-model:visible 进行更新。

因此,首先我们要设置 :hide-on-click-outside="false",防止数字键盘因为点击蒙版意外关闭。

<wd-keyboard
  class="pass-keyboard"
  :hide-on-click-outside="false"
  v-model:visible="showKeyboard"
  mode="custom"
  :close-text="confirmText"
  @input="onPassInput"
  @close="handlePassClose"
  @delete="onPassDelete"
></wd-keyboard>

然后我们会发现一旦点击左下角的键盘按钮,数字键盘就会被收起来,只有点击密码输入框才能弹出。显然这不是我们想要的效果,最终效果应该是数字输入框和密码输入框固定的一直显示。通过观察,弹窗的显示是通过 display 和过渡动画实现的,那么最有效的方式就是样式覆盖了

.pass-keyboard {
  :deep(.wd-popup) {
    position: relative;
    transition: none;
    display: block !important;
  }
}

我们还需要禁止初始化时,弹窗淡入淡出的动画,防止数字键盘出现延迟显示,闪烁的问题

.pass-keyboard {
  :deep(.wd-slide-up-enter),
  :deep(.wd-slide-up-leave-to) {
    transform: none;
  }
}

到这里,我们就能够让数字键盘固定到界面中,作为一个普通的组件使用了。

在悬浮面板中组合 密码输入框 和 数字键盘

现在,我们把 密码输入框 和 数字键盘同时放进 Wot IU 的底部弹窗组件(wd-popup)中,会发现两个组件没有联动起来,所以还需要配合密码输入框的焦点事件, 让数字键盘一直显示。

...
<wd-password-input
  v-model="payPassword"
  :length="maxLength"
  :gutter="10"
  :mask="true"
  :focused="showKeyboard"
  @focus="handlePasswordFocus"
/>
<wd-keyboard
  class="pass-keyboard"
  :hide-on-click-outside="false"
  v-model:visible="showKeyboard"
></wd-keyboard>

...

// 处理密码框聚焦 
function handlePasswordFocus() { 
  // 强制显示键盘
  showKeyboard.value = true; 
}

这样我们就基本完成在不弹出系统输入法的情况下,使用数字虚拟键盘输入框密码的操作了。但是到这里你会发现支付宝的密码弹窗都是自动完成后关闭的,现在我们实现的功能,不能做到自动未完成和关闭弹窗。

不过,我们可以通过自定义数字键盘,增加提交按钮,并监听点击事件实现这个操作。在 @close 我们将关闭动作传递到父组件,让父组件直接关闭最外层的弹窗就可以了。

<wd-keyboard
  class="pass-keyboard"
  :hide-on-click-outside="false"
  v-model:visible="showKeyboard"
  mode="custom"
  :close-text="confirmText"
  @input="onPassInput"
  @close="handlePassClose"
  @delete="onPassDelete"
></wd-keyboard>

// 处理关闭 - 点击确定按钮后直接关闭弹窗
function handlePassClose() {
  if (payPassword.value.length < 6) return;

  // 触发输入完成事件
  emit("input-complete", payPassword.value);
}

如果需要自动完成,那么就直接监听密码输入框的输入位数,手动调用上面的关闭事件就可以了

// 监听密码变化
watch(payPassword, (newVal) => {
  // 密码输入完成后的处理
  if (newVal.length === props.maxLength) {
    // 如果启用自动关闭
    if (props.autoConfirm) {
      // 延迟关闭,让用户能看到输入完成的效果
      setTimeout(() => {
        handlePassClose();
      }, 300);
    }
  }
});

完整实例

最后,我把这个功能封装成了一个组件,只需要在项目中引用这个组件,并且根据输入完成事件做进一步处理就行了。唯一不足的是,当密码输入错误时,不能像支付宝一样停留在弹窗输入层,只能退其次统一关闭后处理接口请求传参。

<template>
  <view>
    <wd-popup v-model="showPasswordPopup" position="bottom" round :close-on-click-overlay="true">
      <view class="pay-pass-popup">
        <div class="pass-top">
          <view class="popup-title"> {{ title }} </view>

          <!-- 密码长度提示 -->
          <view v-if="showLengthHint" class="password-length-hint">
            {{ payPassword.length }}/{{ maxLength }}
          </view>

          <!-- 密码输入框 -->
          <wd-password-input
            v-model="payPassword"
            :length="maxLength"
            :gutter="10"
            :mask="mask"
            :focused="showKeyboard"
            @focus="handlePasswordFocus"
          />
        </div>

        <wd-keyboard
          class="pass-keyboard"
          :hide-on-click-outside="false"
          v-model:visible="showKeyboard"
          mode="custom"
          :close-text="confirmText"
          @input="onPassInput"
          @close="handlePassClose"
          @delete="onPassDelete"
        ></wd-keyboard>
      </view>
    </wd-popup>
  </view>
</template>

<script setup lang="ts">
import { ref, watch } from "vue";

// 定义Props
interface Props {
  // 弹窗标题
  title?: string;
  // 确认按钮文本
  confirmText?: string;
  // 是否显示弹窗
  visible?: boolean;
  // 密码最大长度
  maxLength?: number;
  // 是否显示密码长度提示
  showLengthHint?: boolean;
  // 是否隐藏密码(显示为圆点)
  mask?: boolean;
  // 是否自动关闭(输入完成后)
  autoConfirm?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  title: "请输入支付密码",
  confirmText: "确定",
  visible: true,
  maxLength: 6,
  showLengthHint: false,
  mask: true,
  autoConfirm: false,
});

// 定义Emits
const emit = defineEmits<{
  "input-complete": [value: string];
}>();

const payPassword = ref<string>("");
const showPasswordPopup = defineModel("visible", { default: false });
// 显示键盘
const showKeyboard = ref<boolean>(true);

// 监听密码变化
watch(payPassword, (newVal) => {
  //   console.log("当前密码:", newVal);

  // 密码输入完成后的处理
  if (newVal.length === props.maxLength) {
    // 如果启用自动关闭
    if (props.autoConfirm) {
      // 延迟关闭,让用户能看到输入完成的效果
      setTimeout(() => {
        handlePassClose();
      }, 300);
    }
  }
});

// 键盘输入处理 - 只接受数字
function onPassInput(val: string) {
  // 只接受数字输入
  if (!/^\d$/.test(val)) {
    return;
  }

  // 如果已经输入到最大长度,不再接受输入
  if (payPassword.value.length >= props.maxLength) {
    return;
  }

  // 添加数字到密码
  payPassword.value += val;
}

// 删除处理
function onPassDelete() {
  if (payPassword.value.length > 0) {
    // 删除最后一位
    payPassword.value = payPassword.value.slice(0, -1);
  }
}

// 处理密码框聚焦
function handlePasswordFocus() {
  // 强制显示键盘
  showKeyboard.value = true;
}

// 处理关闭 - 点击确定按钮后直接关闭弹窗
function handlePassClose() {
  if (payPassword.value.length < 6) return;

  // 触发输入完成事件
  emit("input-complete", payPassword.value);

  // 关闭密码输入弹窗
  //   close();
}

// 清空密码
function clearPassword() {
  payPassword.value = "";
}

// 打开弹窗
function open() {
  clearPassword();
  showPasswordPopup.value = true;
}

// 关闭弹窗
function close() {
  showPasswordPopup.value = false;
  clearPassword();
}

// 获取当前密码
function getPassword(): string {
  return payPassword.value;
}

// 暴露方法给父组件
defineExpose({
  open,
  close,
  clearPassword,
  getPassword,
});
</script>

<style lang="scss" scoped>
.pay-pass-popup {
  justify-content: center;
}

.pass-top {
  background-color: #ffffff;
  padding: 40rpx;
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.popup-title {
  font-size: 32rpx;
  font-weight: bold;
  text-align: center;
  color: #333;
}

.password-length-hint {
  font-size: 24rpx;
  text-align: center;
  color: #999;
  margin-top: -10rpx;
}

.pass-keyboard {
  padding: 40rpx 0;
  background-color: #f5f5f5;

  :deep(.wd-popup) {
    position: relative;
    transition: none;
    display: block !important;
  }

  :deep(.wd-key.wd-key--close) {
    background: linear-gradient(37deg, #ff3945 5%, #ff9c4a 80%);
    color: white;
    font-weight: bold;
  }

  :deep(.wd-key) {
    font-size: 32rpx;
    font-weight: 500;
  }

  :deep(.wd-key:active) {
    background-color: #e0e0e0;
  }

  :deep(.wd-key--close:active) {
    background: linear-gradient(37deg, #e6323d 5%, #e68c45 80%);
  }

  :deep(.wd-keyboard__keys) {
    padding: 0 8rpx;
  }

  :deep(.wd-slide-up-enter),
  :deep(.wd-slide-up-leave-to) {
    transform: none;
  }
}

:deep(.wd-password-input__item) {
  width: 45px;
  height: 40px;
  padding: 0;
  background: #f2f2f2;
  border-radius: 10px;
}
</style>

使用示例

<template>
  <ac-pass-popup
    ref="passPopupRef"
    v-model:visible="showPassPopup"
    :title="t('withdrawPage.请输入支付密码')"
    :confirmText="t('withdrawPage.提现')"
    @input-complete="onInputComplete"
  />
</template>

<script setup>
const passPopupRef = ref();

function onRequest(){
    // 接口处理
    ...
    passPopupRef.value.close();
}
</script>

结语

组件库虽然方便了大部分的开发场景,但是在某些情况下,仍然需要自行做类似的功能实现处理。

另外,该组件已经归档到项目 uniapp-vitesse-wot-one

Pinia 超进化!从此不需要 Axios

Pinia Colada 让 Vue 应用中的数据请求变得轻而易举。它构建于 Pinia 之上,彻底消除了数据请求带来的所有复杂度与样板代码。它具备完整的类型支持、可摇树优化,并且遵循与 Pinia 和 Vue 一致的设计理念:简单易上手、灵活可扩展、功能强大,还能实现渐进式接入。

640.png

核心特性

  • ⚡️ 自动缓存:智能客户端缓存,自带请求去重能力
  • 🗄️ 异步状态:简化异步状态管理逻辑
  • 🔌 插件系统:功能强大的插件扩展体系
  • ✨ 乐观更新:服务端响应返回前即可更新 UI
  • 💡 合理默认配置:开箱即用,同时保持全量可配置性
  • 🧩 内置插件:自动重新请求、加载延迟等功能一键启用
  • 📚 类型脚本支持:业界领先的 TypeScript 类型体验
    • 💨 极小包体积:基础核心仅约 2kb,且完全支持摇树优化
  • 📦 零外部依赖:除 Pinia 外无任何第三方依赖
  • ⚙️ 服务端渲染(SSR):原生支持服务端渲染

📝 注意:Pinia Colada 始终致力于持续改进和演进。我们非常欢迎大家针对现有功能或新功能方向提供反馈!同时也高度赞赏对文档、Issue、PR(代码合并请求)的贡献。

安装

npm install pinia @pinia/colada

安装你所需功能对应的插件:

import { createPinia } from 'pinia'  
import { PiniaColadafrom '@pinia/colada'  
  
app.use(createPinia())  
// 需在 Pinia 之后安装  
app.use(PiniaColada, {  
  // 可选配置项  
})

使用方式

Pinia Colada 的核心是 useQuery 和 useMutation 两个函数,分别用于数据查询和数据写入。以下是简单示例:

<script lang="ts" setup>  
import { useRoute } from 'vue-router'  
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'  
import { patchContact, getContactById } from '~/api/contacts'  
  
const route = useRoute()  
const queryCache = useQueryCache()  
  
// 数据查询  
const { data: contact, isPending } = useQuery({  
  // 缓存中该查询的唯一标识  
  key: () => ['contacts', route.params.id],  
  // 实际执行的查询逻辑  
  query: () => getContactById(route.params.id),  
})  
  
// 数据变更  
const { mutate: updateContact, isLoading } = useMutation({  
  // 实际执行的变更逻辑  
  mutation: patchContact,  
  async onSettled({ id }) {  
    // 使上述查询失效,触发数据重新请求  
    await queryCache.invalidateQueries({ key: ['contacts', id], exact: true })  
  },  
})  
</script>  
  
<template>  
  <section>  
    <p v-if="isPending">加载中...</p>  
    <ContactCard  
      v-else  
      :key="contact.id"  
      :contact="contact"  
      :is-updating="isLoading"  
      @update:contact="updateContact"  
    />  
  </section>  
</template>

想了解更多核心概念及使用方式,请查阅官方文档。 pinia-colada.esm.dev/

2025 Vue转React避坑指南:从核心思维到工程实践的完整迁移手册

从Vue3到React19的“被迫”成长之路

作为一名写了三年Vue3的“老前端”,上个月突然接到组长的通知:“咱们下个项目要用React,你带个头转过去。”说实话,我当时心里是抵触的——Vue的模板语法、响应式系统明明用得好好的,为什么要换?但当我真正动手写第一个React组件时,才发现这不是简单的“语法切换”,而是一场“思维革命”

记得那天晚上,我盯着React组件的useState钩子发呆:“为什么Vue的ref能自动更新,React却要手动setCount?”我试着用Vue的习惯写React代码——直接修改count的值,结果页面毫无反应,控制台还报了“状态未更新”的警告。那一刻,我才意识到:Vue的“响应式自动更新”是温柔的陷阱,而React的“手动触发+不可变数据”才是更底层的逻辑

接下来的日子里,我踩了不少坑:用0做条件渲染导致页面显示异常、忘记给列表加key导致控制台报警、用useEffect时没加依赖数组导致无限循环……但正是这些坑,让我真正理解了React的设计哲学——“一切皆函数,一切皆状态”。现在,我想把这些踩坑经验整理成一份“避坑指南”,帮同样从Vue转React的开发者少走弯路。

一、核心思维转变:从“模板指令”到“JSX+函数式”

Vue的核心是模板语法+指令系统v-ifv-forv-model),而React的核心是JSX+函数式组件+Hooks。转React的第一步,就是要放弃“模板思维”,拥抱“JSX逻辑”

1. 模板vs JSX:逻辑与结构的分离

Vue的模板是“HTML扩展”,逻辑(如条件、循环)通过指令实现;React的JSX是“JavaScript扩展”,逻辑通过表达式{})和函数mapfilter)实现。比如:

  • Vue的v-if="show"对应React的{show && <div/>}
  • Vue的v-for="item in list"对应React的{list.map(item => <div key={item.id}/>)}

刚开始写JSX时,我总觉得“不习惯”——为什么要把逻辑写在{}里?但后来发现,JSX的逻辑与结构分离,反而让代码更清晰。比如,我可以用map函数遍历列表,同时在{}里写条件判断,而不用像Vue那样把v-ifv-for混在一起。

2. 指令vs表达式:从“声明式”到“命令式”

Vue的v-bind:classv-on:click是指令,而React的属性绑定(className={active ? 'active' : ''})和事件处理(onClick={handleClick})是表达式。比如:

  • Vue的@click="increment"对应React的onClick={increment}
  • Vue的:class="{ active: isActive }"对应React的className={isActive ? 'active' : ''}

刚开始,我总忘记把v-on改成onClick,把v-bind改成{},但慢慢的,我发现表达式比指令更灵活——我可以动态地拼接类名,比如在React中写className={clsx('btn', { 'btn-active': isActive })}clsx是一个常用的类名合并工具),而Vue的v-bind:class只能写对象或数组。

二、状态管理:从“响应式自动更新”到“手动触发+不可变数据”

Vue的响应式系统refreactive)会自动追踪数据变化并更新视图,而React的状态管理useStateuseReducer)需要手动触发更新,且要求不可变数据(不能直接修改原状态)。这是Vue转React最容易踩坑的地方。

1. 状态更新方式:从“自动”到“手动”

Vue中,count.value++会自动更新视图;React中,setCount(count + 1)必须返回新状态,否则React无法检测到状态变化。比如:

  • Vue的user.name = 'Bob'会自动更新视图;
  • React的setUser({ ...user, name: 'Bob' })必须创建新对象,否则视图不会更新。

我记得有一次,我写了一个表单组件,直接用user.email = e.target.value修改状态,结果页面上的输入框没有更新。查了半天才知道,React的状态是“不可变的”,必须通过setState返回新状态。从那以后,我养成了“永远不修改原状态”的习惯。

2. Hooks对应:从“Vue的组合式API”到“React的Hooks”

Vue的ref()对应React的useState()computed()对应useMemo()watch()对应useEffect()。比如:

  • Vue的const count = ref(0)对应React的const [count, setCount] = useState(0)
  • Vue的const double = computed(() => count.value * 2)对应React的const double = useMemo(() => count * 2, [count])
  • Vue的watch(count, (newVal) => console.log(newVal))对应React的useEffect(() => console.log(count), [count])

刚开始,我总把useMemo当成computed用,但后来发现,**useMemo更适合缓存计算结果,而computed更适合依赖追踪**。比如,当count变化时,useMemo会重新计算double,而computed会自动追踪count的变化。

三、路由配置:从“Vue Router选项式”到“React Router v6函数式”

2025年,React路由的主流方案是React Router v6,与Vue Router的选项式配置routes数组)不同,React Router v6采用函数式+嵌套路由的方式,需要适应以下变化:

1. 路由定义:从“数组”到“函数”

Vue Router的routes数组对应React Router v6的createBrowserRouter函数。比如:

  • Vue的const routes = [{ path: '/', component: Home }]
  • React的const router = createBrowserRouter([{ path: '/', element: <Home /> }])

刚开始,我觉得createBrowserRouter比Vue的routes数组复杂,但后来发现,函数式的路由定义更灵活——我可以动态地添加路由,比如根据用户权限显示不同的路由。

2. 路由参数获取:从“$route”到“useParams”

Vue Router的this.$route.params.id对应React Router v6的**useParams Hook(客户端)或params参数**(服务器组件,如Next.js 15)。比如:

  • React Router v6客户端组件:const { id } = useParams()
  • Next.js 15服务器组件:export default async function Page({ params }) { const { id } = await params; }

我记得有一次,我写了一个用户详情页,用useParams获取id,结果页面报错——“params is undefined”。查了文档才知道,**useParams只能在客户端组件中使用**,如果是服务器组件,必须用params参数。

3. 编程式导航:从“$router.push”到“useNavigate”

Vue Router的this.$router.push('/profile')对应React Router v6的**useNavigate Hook**。比如:

  • Vue的this.$router.push('/profile')
  • React的const navigate = useNavigate(); navigate('/profile')

刚开始,我总忘记把$router.push改成navigate,但后来发现,**useNavigate$router.push更灵活**——我可以前进或后退,比如navigate(-1)(后退一页)。

四、常见错误避免:从“Vue习惯”到“React规范”

Vue转React时,容易犯以下典型错误,需特别注意:

1. 用0做条件渲染

React中,0有效值(会渲染到页面),而Vue中0会被当作“假值”。比如:

  • Vue中{items.length || <Empty/>}没问题,但React中{items.length || <Empty/>}会渲染0(如果items.length为0),正确做法是{items.length > 0 ? <List/> : <Empty/>}

我记得有一次,我写了一个商品列表,用{items.length || <Empty/>}显示空状态,结果页面上显示了0,用户以为列表里有0个商品。后来,我改成了{items.length > 0 ? <List/> : <Empty/>},才解决问题。

2. 突变状态

React要求不可变数据,直接修改原状态(如user.age = 20)不会触发视图更新,必须用setUser返回新状态(如setUser(prev => ({ ...prev, age: 20 })))。

3. 忘记key属性

React中,列表渲染(map)必须给每个元素加**唯一key**(如item.id),否则会出现“渲染异常”。key不能用index(会导致性能问题),必须从数据中获取唯一标识(如crypto.randomUUID())。

4. useEffect无限循环

useEffect的依赖数组([])必须包含所有用到的状态,否则会导致“无限循环”。比如:

  • 错误示例:useEffect(() => { getUser(userId).then(setUser); }, [])(用到了userId,但依赖数组为空);
  • 正确示例:useEffect(() => { getUser(userId).then(setUser); }, [userId])(将userId加入依赖数组)。

5. setState后立即访问状态

setState异步的,立即访问状态会得到“旧值”。比如:

  • const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); console.log(count); }(输出0,旧值);
  • 正确做法:用useEffect监听状态变化,比如useEffect(() => console.log(count), [count])(输出1,新值)。

五、工具与生态:从“Vue CLI”到“Vite+React生态”

2025年,React的开发工具链以Vite(构建工具)、React Router v6(路由)、状态管理方案(如Zustand、Redux Toolkit)为主,需适应以下变化:

1. 构建工具:从“Vue CLI”到“Vite”

Vue常用Vue CLI,而React推荐Vite(更快的热更新、更小的包体积)。创建React项目的命令是:npm create vite@latest my-react-app -- --template react-ts

2. 状态管理方案:从“Pinia”到“Zustand/Redux Toolkit”

  • 小型项目:用useState + useContext(React内置,无需额外依赖);
  • 中型项目:用Zustand(轻量级,API简洁,适合快速开发);
  • 大型项目:用Redux Toolkit(官方推荐,强大的调试工具,适合复杂状态逻辑)。

3. 样式工具:从“Tailwind CSS”到“Tailwind CSS+clsx”

React中常用的样式工具是Tailwind CSS(原子化CSS,快速构建UI)、class-variance-authority(管理组件变体)、clsx(条件性组合类名)。比如:

import { twMerge } from 'tailwind-merge';
import clsx from 'clsx';

const Button = ({ variant, size, className, children }) => {
  return (
    <button
      className={twMerge(
        clsx(
          'inline-flex items-center justify-center rounded-md font-medium',
          {
            'bg-blue-600 text-white': variant === 'primary',
            'bg-gray-200 text-gray-800': variant === 'secondary',
            'h-9 px-3 text-sm': size === 'sm',
            'h-10 px-4 text-base': size === 'md',
          },
          className
        )}
      )}
    >
      {children}
    </button>
  );
};

六、实战技巧:从“Vue组件”到“React组件”的快速转换

以下是Vue组件转React组件的具体示例,覆盖模板、状态、事件等核心部分:

1. Vue组件(Composition API)

<template>
  <div class="card">
    <h2>{{ title }}</h2>
    <p>{{ content }}</p>
    <button @click="increment">点击次数:{{ count }}</button>
  </div>
</template>
<script setup>
import { ref } from 'vue';
const title = ref('Vue 组件');
const content = ref('这是 Vue 的内容');
const count = ref(0);
const increment = () => count.value++;
</script>
<style scoped>
.card { border: 1px solid #eee; padding: 20px; }
</style>

2. React组件(函数式+Hooks)

import { useState } from 'react';
import clsx from 'clsx';

const Card = () => {
  const [title] = useState('React 组件');
  const [content] = useState('这是 React 的内容');
  const [count, setCount] = useState(0);
  const increment = () => setCount(count + 1);
  return (
    <div className={clsx('card', 'border border-gray-200 p-5')}>
      <h2>{title}</h2>
     </p>
      <button onClick={increment}>点击次数:{count}</button>
    </div>
  );
};
export default Card;

关键变化

  • 模板→JSX(用{}绑定数据);
  • ref()useState()(状态管理);
  • @clickonClick(事件处理);
  • scoped样式→用clsxTailwind CSS(条件性样式)。

七、进阶建议:从“会用React”到“精通React”

1. 学习Hooks高级用法

比如useMemo(缓存计算结果)、useCallback(缓存函数引用)、useRef(获取DOM元素或跨渲染周期变量)。比如:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
const memoizedCallback = useCallback(() => doSomething(a, b), [a, b]);

2. 掌握React Router v6高级特性

比如嵌套路由Outlet组件)、路由守卫loaderaction)、懒加载React.lazy+Suspense)。

3. 学习状态管理方案

比如Zustand(轻量级)、Redux Toolkit(企业级),掌握状态拆分(如将用户信息、主题设置拆分为不同store)。

4. 适应React生态

比如Next.js(全栈React框架,支持服务器组件、静态生成)、shadcn/ui(零依赖组件库)、react-hook-form(高性能表单处理)。

总结:Vue转React的核心逻辑

Vue转React的本质是从“模板指令”到“JSX逻辑”、从“响应式自动更新”到“手动触发+不可变数据”的思维转变。关键是要放弃Vue的习惯,拥抱React的函数式+Hooks范式,同时注意常见错误(如突变状态、useEffect无限循环)。

通过实战项目(如Todo List、博客系统)练习,可以快速掌握React的核心技能,适应React的生态。如果需要更详细的迁移指南,可以参考**vue-to-react工具(自动化转换Vue组件为React组件)或Veaury**(跨框架组件互操作),降低迁移成本。

最后,我想对同样从Vue转React的开发者说:不要害怕踩坑,因为每一个坑都是成长的机会。当你真正理解了React的设计哲学,你会发现,它比Vue更灵活、更强大。

vue 甘特图 vxe-gantt 设置每个进度条分为计划和实际两条,实现上下分布任务条

vue 甘特图 vxe-gantt 设置每个进度条分为计划和实际两条,实现上下分布任务条,实现方式是利用子任务的子视图渲染模式,来间每条任务拆分成2条子任务,就可以利用自带的子视图渲染功能来渲染。

gantt.vxeui.com

由于放2行超出默认高度,所以还需要通过 cell-config.height设置一下行高,再通过树形表格的子任务来渲染

image

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

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

const ganttOptions = reactive({
  border: true,
  height: 500,
  loading: false,
  cellConfig: {
    height: 60
  },
  treeConfig: {
    transform: true,
    rowField: 'id',
    parentField: 'parentId'
  },
  taskConfig: {
    startField: 'start',
    endField: 'end',
    typeField: 'type'
  },
  taskBarSubviewConfig: {
    barStyle ({ row }) {
      if (row.flag === 1) {
        return {
          transform: 'translateY(-24px)',
          '--vxe-ui-gantt-view-task-bar-completed-background-color': '#409eff'
        }
      }
      if (row.flag === 2) {
        return {
          transform: 'translateY(1px)',
          '--vxe-ui-gantt-view-task-bar-completed-background-color': '#31d231'
        }
      }
    }
  },
  taskBarConfig: {
    showContent: true,
    barStyle: {
      round: true
    }
  },
  taskViewConfig: {
    tableStyle: {
      width: 480
    }
  },
  columns: [
    { field: 'title', title: '任务名称', minWidth: 100 },
    { field: 'planStartDate', title: '计划开始时间', width: 100 },
    { field: 'planEndDate', title: '计划结束时间', width: 100 },
    { field: 'actualStartDate', title: '实际开始时间', width: 100 },
    { field: 'actualEndDate', title: '实际结束时间', width: 100 }
  ],
  data: []
})

// 模拟后端接口
const loadList = () => {
  ganttOptions.loading = true
  setTimeout(() => {
    const list = [
      { id: 10001, parentId: null, title: 'A项目', planStartDate: '2024-03-03', planEndDate: '2024-03-15', actualStartDate: '2024-03-03', actualEndDate: '2024-03-12' },
      { id: 10002, parentId: null, title: 'B项目', planStartDate: '2024-03-10', planEndDate: '2024-03-25', actualStartDate: '2024-03-08', actualEndDate: '2024-03-16' },
      { id: 10003, parentId: null, title: 'C项目', planStartDate: '2024-03-20', planEndDate: '2024-04-10', actualStartDate: '2024-03-22', actualEndDate: '2024-04-01' },
      { id: 10004, parentId: null, title: 'D项目', planStartDate: '2024-03-28', planEndDate: '2024-04-19', actualStartDate: '2024-03-28', actualEndDate: '2024-04-12' },
      { id: 10005, parentId: null, title: 'E项目', planStartDate: '2024-04-05', planEndDate: '2024-04-28', actualStartDate: '2024-04-01', actualEndDate: '2024-04-24' }
    ]
    // 转成子任务视图
    const ganttData = []
    list.forEach(item => {
      const currRow = XEUtils.assign({}, item, { type: VxeGanttTaskType.Subview })
      const planRow = XEUtils.assign({}, item, {
        id: 10000000 + item.id,
        title: '计划',
        parentId: item.id,
        start: item.planStartDate,
        end: item.planEndDate,
        flag: 1
      })
      const actualRow = XEUtils.assign({}, item, {
        id: 20000000 + item.id,
        parentId: item.id,
        title: '实际',
        start: item.actualStartDate,
        end: item.actualEndDate,
        flag: 2
      })
      ganttData.push(currRow)
      ganttData.push(planRow)
      ganttData.push(actualRow)
    })
    ganttOptions.data = ganttData
    ganttOptions.loading = false
  }, 200)
}

loadList()
</script>

gitee.com/x-extends/v…

Vue3 响应式数据常用方案及实践坑点

最近在做 Vue 项目相关的需求,复习一下 Vue 的响应式机制及其常用办法

从 Vue3 视角来看,它的响应式数据核心就是refreactive,都依赖于 ES6 的Proxy API,以此来代理监听整个对象,从而能关注到复杂数据类型内部属性的增删改的变化。值得一提的是,这里代理对象包含了多嵌套式对象的情况,也就是可以实现深度监听。

相较于 Vue2 的defineProperty()仅对于属性层面的监听,无疑在构建复杂数据类型的响应式时,性能提升是巨大的。

下面让我们聊聊无处不在的refreactive:

ref

ref通常用来包装基本数据类型,由于Proxy是对于复杂数据类型的 API,所以它的实质是在Proxy包装的基础上又在外封装了一层,所以我们需要用.value来读写数据,但在<template>模板中访问响应式数据无需.value因为此时已经做了解包的处理。

reactive

对于reactive相对的便是用来包装复杂数据类型,诸如ObjectArray这样的数据,他可以直接监听整个对象的属性操作(增删改)。但要注意的是,切勿直接操作这个对象,也就是说不要改变这个reactive数据的引用,这会使他丢失响应式。

二者怎么抉择呢,尤大大提倡使用ref,事实也正是如此,绝大多数场景,简单和复杂数据类型均使用ref构建响应式,虽然理论上全部加一层包装会有性能损耗,但对于团队代码可读性和可维护性,这点损耗微乎其微。下面举个例子:

// 情景:初始化一个 list,后续调接口拿到数据 res.data,需要赋值(先不考虑使用 TS)
// 使用 reactive
const list = reactive({})
Object.keys(res.data).forEach(key => {
    list.key = res.data[key]
})
// 使用 ref
const list = ref({})
list.value = res.data
// 或更严谨
list.value = {...list.value, ...res.data}

高下立判,无论从可读性还是维护性上讲ref也是完胜的。当然对于一些构造表单模板即对象属性增删不频繁的场景reactive不免为更优雅的选择...

以上是在学习阶段对于两个兄弟的基本认识。

响应式数据在组件间通信

说起这点,最常用的便是父子组件间props+emit的通信

父→子:通过props传给子组件,子组件可直接使用,值得一提的是这里的props虽然是响应式的但我们不能直接通过props.a来修改,这虽然可行但违背了 Vue 单向数据流的原则会报错,试想如果一个响应式数据想在哪里修改就在哪里修改,姑且不说可能导致的异常,就代码规范性而言就不过关

子→父:所以我们通过emit的方法来修改,通过$emit触发父组件传给子组件的事件类型,父组件监听并响应触发事件

这里以 Vue3 组合式 API 的写法为例:

// Parent.vue -->
<template>
  <Child :count="count" @update-count="handleUpdate" />
</template>

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

const count = ref(0)
const handleUpdate = (newVal) => {
  count.value = newVal
}
</script>

// Child.vue -->
<template>
  <button @click="update">Count: {{ count }}</button>
</template>

<script setup>
const props = defineProps(['count'])
const emit = defineEmits(['update-count'])

const update = () => {
  emit('update-count', props.count + 1)
}
</script>

这里仅仅讲述最常用的通信,还有provide+injectpinia/vuex这里便不再赘述

常见坑点

我们在接收到porps的数据以后,如果父组件传的是一个ref,或者是reactive,或者是非响应式?我们子组件接受到该怎么使用,需要加.value?可以直接使用?还是需要传给中间值?怎么传?

这些问题可能在学习阶段无需思考,已经知道了怎么用就顺着来写,但我们需要考虑的是如果好久不用了,我们能否通过自己的技术深度来知道怎么使用是正确的,怎么使用不会丢失响应式?不会导致异常?

1、如果传值是ref/reactive/非响应,子组件如何用?

结论:无论父组件传的是 refreactive 还是普通对象,子组件通过 props 接收到的都是一个「普通响应式对象」,也就是Proxy,你永远不需要、也不应该在子组件中对props使用.value

原因:Vue 对 props 的统一处理机制

当你在父组件这样传递数据:

// 父组件
const a = ref({ name: 'Alice' })        // ref
const b = reactive({ name: 'Bob' })     // reactive
const c = { name: 'Charlie' }           // 普通对象

<Child :data-a="a" :data-b="b" :data-c="c" />

Vue 在传递给子组件前,会自动将所有值标准化为响应式对象(如果还不是的话),并注入到 props 中。

子组件接收到的 props 是一个由 Vue 内部创建的 响应式 Proxy 对象,结构如下:

// 子组件中的 props(概念上)
props = reactive({
  dataA: { name: 'Alice' },   // ← 已解包 ref,并转为响应式
  dataB: { name: 'Bob' },     // ← 原 reactive 对象(或其代理)
  dataC: { name: 'Charlie' }  // ← 普通对象被自动 reactive 包装
})

所以:props 中的每个属性都已经是“解包后”的响应式对象,无需 .value

2、该如何使用?

结论:始终通过 props.xxx 访问数据(不解构、不赋值给顶层变量),并在需要修改但不影响源数据时创建本地副本。

原因

先看个错误的示例:

❌ 错误做法:解构或顶层赋值

setup(props) {
  const { name } = props.user;     // ❌ name 是普通字符串,失去响应式
  const age = props.user.age;      // ❌ age 是快照引用,不会随父更新

  // 后续使用 name/age 都是非响应式的!
}

有的兄弟可能要说了,我们有时候就只是需要其中的一个属性数据,也不用响应式,这样直接拿到不就好了?

但是请注意,如果父组件传的数据是异步获取的,当你直接结构或取值时可能拿到的是执行完异步操作前的数据,也就是说可能永远拿到的都是初始化时的空数据,因为就算异步操作完成,也会因丢失响应式而不会更新数据,造成问题!

🔔 ESLint 规则 vue/no-setup-props-destructure 就是为了防止这类错误。

所以始终通过 props.xxx 访问数据(不解构、不赋值给顶层变量)

而当我们本地需要创建副本来维护这个数据,但不影响父组件时:

import { ref, watch } from 'vue'
import _ from 'lodash' // 或自定义 deepClone

setup(props) {
  // 创建深度独立副本(保持本地响应式)
  const localUser = ref(_.cloneDeep(props.user));

  // 可选:监听 prop 变化以重置本地状态(如父组件刷新数据)
  watch(() => props.user, (newUser) => {
    localUser.value = _.cloneClone(newUser);
  });

  const updateName = (name) => {
    localUser.value.name = name; // ✅ 修改本地副本,不影响父组件
  };

  return { localUser, updateName };
}

终极建议:

  • 模板中:直接写 {{ props.xxx.yyy }} ✅

  • setup 中

    • 只读 → 用 props.xxx 或 () => props.xxx(在 watch/computed 中)
    • 需修改 → 创建 ref(deepClone(props.xxx)) 作为本地状态
  • 绝不在 setup 顶层解构 props 或赋值给普通变量

  • 修改数据 → 通过 emit 通知父组件,或操作本地副本

实力不济,新人小白,持续更新...

2026重磅Uniapp+Vue3+DeepSeek-V3.2跨三端流式AI会话

迎接马年新春,历时三周爆肝迭代研发uni-app+vue3对接deepseek-v3.2聊天大模型。新增深度思考、katex数学公式、代码高亮/复制代码等功能。

未标题-20.png

p1-1.gif

H5端还支持mermaid图表渲染,小程序端支持复制代码。

p2-1.gif

未标题-12-xcx3.png

app6.gif

未标题-7.png

使用技术

  • 开发工具:HbuilderX 4.87
  • 技术框架:uni-app+vue3+pinia2+vite5
  • 大模型框架:DeepSeek-V3.2
  • 组件库:uni-ui+uv-ui
  • 高亮插件:highlight.js
  • markdown解析:ua-markdown+mp-html
  • 本地缓存:pinia-plugin-unistorage

未标题-16.png

编译支持

360截图20260208114808097.png

另外还支持运行到web端,以750px显示页面布局结构。

014360截图20260207222047559.png

015360截图20260207222357329.png

016360截图20260207223029831.png

017360截图20260207224414288.png

017360截图20260207225332423.png

017360截图20260207225332429.png

018360截图20260207225701329.png

如果想要了解更多的项目介绍,可以去看看这篇文章。

uniapp+deepseek流式ai助理|uniapp+vue3对接deepseek三端Ai问答模板

往期推荐

2026最新款Vue3+DeepSeek-V3.2+Arco+Markdown网页端流式生成AI Chat

Electron39.2+Vue3+DeepSeek从0-1手搓AI模板桌面应用Exe

2026最新款Vite7+Vue3+DeepSeek-V3.2+Markdown流式输出AI会话

electron38.2-vue3os系统|Vite7+Electron38+Pinia3+ArcoDesign桌面版OS后台管理

基于electron38+vite7+vue3 setup+elementPlus电脑端仿微信/QQ聊天软件

2025最新款Electron38+Vite7+Vue3+ElementPlus电脑端后台系统Exe

自研2025版flutter3.38实战抖音app短视频+聊天+直播商城系统

基于uni-app+vue3+uvui跨三端仿微信app聊天模板【h5+小程序+app】

基于uniapp+vue3+uvue短视频+聊天+直播app系统

基于flutter3.32+window_manager仿macOS/Wins风格桌面os系统

flutter3.27+bitsdojo_window电脑端仿微信Exe应用

自研tauri2.0+vite6.x+vue3+rust+arco-design桌面版os管理系统Tauri2-ViteOS

❌