阅读视图

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

我封装了一个“瑞士军刀”级插件,并顺手搞定了自动化部署

Nuxt 3 开发提效指南:我封装了一个“瑞士军刀”级插件,并顺手搞定了自动化部署

在 Nuxt 3 的开发过程中,我们经常会遇到一些重复性的工作:封装 Fetch 请求、处理 AES/RSA 加密、配置 SEO Meta、判断设备类型等等。虽然社区有很多优秀的库,但每次新开项目都要重新把这些库集成一遍,还是略显繁琐。

于是,我开发了 nuxt-web-plugin,一个集成了网络请求、安全加密、SEO 优化、设备检测等常用功能的 Nuxt 3 插件,旨在让开发变得更简单、更高效。

GitHub 仓库github.com/hangjob/nux…
在线文档hangjob.github.io/nuxt-web-pl…

🚀 为什么需要这个插件?

在实际业务开发中,我们往往需要解决以下痛点:

  1. API 请求繁琐:原生 useFetch 虽然好用,但缺乏统一的拦截器、错误处理和 Token 自动注入。
  2. 数据安全焦虑:前后端交互敏感数据(如密码、手机号)裸奔,手动引入 crypto-jsjsencrypt 体积大且配置麻烦。
  3. SEO 配置重复:每个页面都要手写 useHead,不仅累还容易漏掉 Open Graph 标签。
  4. 设备适配麻烦:需要手动解析 User-Agent 来判断是移动端还是 PC 端,或者是否在微信环境内。

nuxt-web-plugin 就是为了解决这些问题而生的。它不是一个臃肿的 UI 库,而是一套轻量级的业务逻辑增强套件

✨ 核心功能一览

1. 优雅的网络请求 (useApiClient)

基于 Nuxt useFetch 的深度封装,支持全局拦截器、自动携带 Token、统一错误处理。

const api = useApiClient()

// GET 请求
const { data } = await api.get('/user/profile')

// POST 请求(自动处理 Content-Type)
await api.post('/auth/login', { body: { username, password } })

nuxt.config.ts 中简单配置即可生效:

export default defineNuxtConfig({
  modules: ['nuxt-web-plugin'],
  webPlugin: {
    network: {
      baseURL: 'https://api.example.com',
      timeout: 10000
    }
  }
})

2. 开箱即用的安全加密 (useCrypto, useWebUtils)

内置了 AES 对称加密、RSA 非对称加密和 Hash 哈希计算,无需额外安装依赖。

const { encrypt, decrypt } = useSymmetricCrypto() // AES
const { encrypt: rsaEncrypt } = useAsymmetricCrypto() // RSA
const { hash } = useHash() // MD5, SHA-256

// 示例:登录密码加密
const encryptedPassword = rsaEncrypt(password)
// 示例:本地存储敏感数据
const secureData = encrypt(userData)

3. 懒人版 SEO 优化 (useWebSeo)

一行代码搞定 Title、Description、Keywords 以及 Open Graph 社交分享卡片。

useWebSeo({
  title: '我的文章标题',
  description: '这是一篇关于 Nuxt 3 插件的介绍文章',
  image: '/cover.png' // 自动转换为绝对路径
})

4. 设备与环境检测 (useDevice)

在 SSR 和客户端均可准确识别设备类型。

const { isMobile, isDesktop, isWeChat, isIOS } = useDevice()

if (isMobile) {
  // 加载移动端组件
}

🛠️ 附加技能:如何使用 GitHub Actions 自动部署文档

在这个项目的开发过程中,我使用了 VitePress 编写文档,并利用 GitHub Actions 实现了自动化部署到 GitHub Pages。这里分享一下我的踩坑经验和最终方案。

1. 准备 VitePress

首先,确保你的文档项目(通常在 docs 目录)能正常 build。

docs/.vitepress/config.mts 中,最关键的一步是设置 base 路径,必须与你的 GitHub 仓库名一致:

export default defineConfig({
  // 如果仓库地址是 https://github.com/hangjob/nuxt-web-plugin
  // 那么 base 必须是 /nuxt-web-plugin/
  base: '/nuxt-web-plugin/', 
  // ...
})

2. 配置 GitHub Actions

在项目根目录创建 .github/workflows/docs.yml

遇到的坑

  1. 权限不足:GitHub Actions 默认只有只读权限,无法推送到 gh-pages 分支。需要在仓库 Settings -> Actions -> General -> Workflow permissions 中开启 Read and write permissions
  2. pnpm 找不到:Actions 环境默认没有 pnpm,需要专门安装。
  3. 锁文件问题:如果不想提交 pnpm-lock.yaml,安装依赖时不能用 --frozen-lockfile

最终可用的配置(亲测有效)

name: Deploy Docs

on:
  push:
    branches: [main] # 推送 main 分支时触发

permissions:
  contents: write # 显式赋予写权限

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # 1. 安装 pnpm
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9
          run_install: false

      # 2. 设置 Node 环境 (推荐 LTS v20 或 v22)
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm # 如果提交了锁文件,可以用这个加速

      # 3. 安装依赖 (无锁文件模式)
      - name: Install deps
        run: pnpm install --no-frozen-lockfile

      # 4. 解决 Nuxt 特有的构建问题 (生成 .nuxt 目录)
      - name: Prepare Nuxt
        run: pnpm run dev:prepare 

      # 5. 构建文档
      - name: Build docs
        run: pnpm docs:build

      # 6. 发布到 gh-pages 分支
      - name: Deploy to GitHub Pages
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: docs/.vitepress/dist

3. 自动化

配置完成后,每次 git push,GitHub Actions 就会自动帮你构建文档并发布。你可以通过 https://<username>.github.io/<repo-name>/ 访问你的文档站点。


💡 结语

nuxt-web-plugin 目前还在持续迭代中,希望能为你的 Nuxt 开发之旅减少一些重复劳动。如果你觉得有用,欢迎来 GitHub 点个 Star ⭐️!

从后端拼模板到 Vue 响应式:前端界面的三次进化

从后端拼模板到 Vue 响应式:一场前端界面的进化史

当开始学习前端开发时,很多人都会遇到一个共同的困惑:
为什么有的项目让后端直接返回 HTML?

为什么后来大家都开始使用 fetch 拉取 JSON?

而现在又流行 Vue 的响应式界面,几乎不再手动操作 DOM?

这些不同的方式看似杂乱,其实背后隐藏着一条非常清晰的技术发展路径。后端渲染 → 前端渲染 → 响应式渲染它们不是独立出现的,而是前端能力逐步增强、分工越来越明确后的必然产物。

1. 🌱 第一阶段:后端拼模板 —— “厨师把菜做好端到你桌上”

让我们从你最初的 Node.js 服务器代码说起。

    const http = require("http");// Node.js 内置模块,用于创建 HTTP 服务器或客户端
const url = require("url");// 用于解析 URL

const users = [
  { id: 1, name: '张三', email: '123@qq.com' },
  { id: 2, name: '李四', email: '1232@qq.com'},
  { id: 3, name: '王五', email: '121@qq.com' }
];

// 将 `users` 数组转换为 HTML 表格字符串
function generateUserHTML(users){
  const userRows = users.map(user => `
    <tr>
      <td>${user.id}</td>
      <td>${user.name}</td>
      <td>${user.email}</td>
    </tr>
  `).join('');

  return `
    <html>
      <body>
        <h1>Users</h1>
        <table>
          <tbody>${userRows}</tbody>
        </table>
      </body>
    </html>
  `;
}

// 创建一个 HTTP 服务器,传入请求处理函数
const server = http.createServer((req, res) => {
  if(req.url === '/' || req.url === '/users'){
    res.setHeader('Content-Type', 'text/html;charset=utf-8');
    res.end(generateUserHTML(users));
  }
});

//  服务器监听本地 1314 端口。
server.listen(1314);

访问1314端口,得到的结果:

image.png 这段代码非常典型,体现了早期 Web 的模式:

  • 用户访问 /users
  • 后端读取数据
  • 后端拼出 HTML
  • 后端把完整页面返回给浏览器

你可以把它理解成:

用户去餐馆点菜 → 厨房(后端)把菜做好 → 端到你桌上(浏览器)

整个过程中,浏览器不参与任何加工,它只是“展示已经做好的菜”。


🔍 后端拼模板的特点

特点 说明
后端掌控视图 HTML 是后端生成的
数据和页面耦合在一起 改数据就要改 HTML 结构
刷新页面获取新数据 无法局部更新
用户体验一般 交互不够流畅

这种方式在早期 Web 非常普遍,就是典型的 MVC:

  • M(Model): 数据
  • V(View): HTML 模板
  • C(Controller): 拼 HTML,返回给浏览器

“后端拼模板”就像饭店里:

  • 厨师(后端)把所有食材(数据)做成菜(HTML)
  • 顾客(浏览器)只能被动接受

这当然能吃饱,但吃得不灵活。

为了吃一个小菜,还要大厨重新做一桌菜!

这就导致页面每个小变化都得刷新整个页面。


2. 🌿 第二阶段:前后端分离 —— “厨师只给食材,顾客自己配菜”

随着前端能力提升,人们发现:

让后端拼页面太麻烦了。

于是产生了 前后端分离


🔸 后端从“做菜”变成“送食材”(只返回 JSON)

{
  "users": [
    { "id": 1, "name": "张三", "email": "123@qq.com" },
    { "id": 2, "name": "李四", "email": "1232@qq.com" },
    { "id": 3, "name": "王五", "email": "121@qq.com" }
  ]
}

JSON Server 会把它变成一个 API:

GET http://localhost:3000/users

访问该端口得到:

image.png

访问时返回纯数据,而不再返回 HTML。


🔸 前端浏览器接管“配菜”(JS 渲染 DOM)

<script>
fetch('http://localhost:3000/users')// 使用浏览器内置的 `fetch`() API 发起 HTTP 请求。
  .then(res => res.json())//  解析响应为 JSON
  // 渲染数据到页面
  .then(data => {
    const tbody = document.querySelector('tbody');
    tbody.innerHTML = data.map(user => `
      <tr>
        <td>${user.id}</td>
        <td>${user.name}</td>
        <td>${user.email}</td>
      </tr>
    `).join('');
  });
</script>

浏览器自己:

  1. 发送 fetch 请求
  2. 拿到 JSON
  3. 用 JS 拼 HTML
  4. 填入页面

image.png


这就好比:

  • 后端: 不做菜,只把干净的食材准备好(纯 JSON)
  • 前端: 自己按照 UI 要求把菜炒出来(DOM 操作)
  • 双方分工明确,互不干扰

这就是现代 Web 最主流的模式 —— 前后端分离


🚧 但问题来了:DOM 编程太痛苦了

你看这段代码:

tbody.innerHTML = data.map(user => `
  <tr>...</tr>
`).join('');

是不是很像在手工组装乐高积木?

DOM 操作会遇到几个痛点:

  • 代码又臭又长
  • 更新数据要重新操作 DOM
  • 状态多了之后难以维护
  • 页面结构和业务逻辑混在一起

前端工程师开始苦恼:

有没有一种方式,让页面自动根据数据变化?

于是,Vue、React、Angular 出现了。


3. 🌳 第三阶段:Vue 响应式数据驱动 —— “只要食材变化,餐盘自动变化”

Vue 的核心理念:

ref 响应式数据,将数据包装成响应式对象
界面由 {{}} v-for 进行数据驱动
专注于业务,数据的变化而不是 DOM

这是前端的终极模式 —— 响应式渲染


🔥 Vue 的思想

Vue 做了三件事:

  1. 把变量变成“会被追踪的数据”(ref / reactive)
  2. 把 HTML 变成“模板”(用 {{ }}、v-for)
  3. 让数据变化自动修改 DOM

你只需要像写伪代码一样描述业务:

<script setup>
import {
  ref,
  onMounted // 挂载之后
} from 'vue'
const users = ref([]);

// 在挂载后获取数据

onMounted(() =>{
   fetch('http://localhost:3000/users')
   .then(res => res.json())
   .then(data => {
    users.value = data;
   })
})
</script>

而页面模板:

<tr v-for="u in users" :key="u.id">
  <td>{{ u.id }}</td>
  <td>{{ u.name }}</td>
  <td>{{ u.email }}</td>
</tr>

得到的结果为:

image.png 你不再需要:

  • querySelector
  • innerHTML
  • DOM 操作

Vue 会自己完成这些工作。


如果 传统 DOM:

你要把所有食材手动摆到盘子里。

那么Vue:

你只需要放食材到盘子里(修改数据),
餐盘的摆盘会自动变化(界面自动更新)。

比如你修改了数组:

users.value.push({ id: 4, name: "新用户", email: "xxx@qq.com" });

页面会自动新增一行。

你删除:

users.value.splice(1, 1);

页面自动少一行。

你完全不用动 DOM。


4. 🌲 三个阶段的对比

阶段 数据从哪里来? 谁渲染界面? 技术特征
1. 后端渲染(server.js) 后端 后端拼 HTML 模板字符串、MVC
2. 前端渲染(index.html + db.json) API / JSON 前端 JS DOM Fetch、innerHTML
3. Vue 响应式渲染 API / JSON Vue 自动渲染 ref、{{}}、v-for

本质是渲染责任的迁移:

后端渲染 → 前端手动渲染 → 前端自动渲染

最终目标只有一个:

让开发者把时间花在业务逻辑,而不是重复性 DOM 操作上。


5. 🍁 为什么现代开发必须用前后端分离 + Vue?

最后,让我们用一句最通俗的话总结:

后端拼页面像“饭店厨师包办一切”,效率低。

前端手动拼 DOM 像“自己做饭”,累到爆。

Vue 像“智能厨房”,你只需要准备食材(数据)。


Vue 的三大优势

1)极大减少开发成本

业务逻辑变简单:

users.value = newUsers;

就够了,UI 自动更新。

2)更适合大型项目

  • 组件化
  • 模块化
  • 状态集中管理
  • 可维护性高

3)用户体验更好

  • 页面不刷新
  • 更新局部
  • 响应迅速

6. 🌏 文章总结:从“厨房”看前端的进化历史

最终,我们回到开头的类比:

阶段 类比
第一阶段:后端拼模板 厨房(后端)做好所有菜,直接端给你
第二阶段:前端渲染 厨房只提供食材,你自己炒
第三阶段:Vue 响应式 智能厨房:只要食材变,菜自动做好

前端技术每一次进化,都围绕同一个核心目标:

让开发者更轻松,让用户体验更好。

而你上传的代码正好构成了一个完美的演示链路:
从最原始的后端拼模板,到 fetch DOM 渲染,再到 Vue 响应式渲染。

理解了这三步,你就理解了整个现代前端技术的发展脉络。


从模板渲染到响应式驱动:前端崛起的技术演进之路

引言:界面是如何“动”起来的?

不论是用户看到的哪个页面,都不应该是一成不变的静态 HTML。

待办事项的增删、商品库存的实时变化,还是聊天消息的即时推送,都让页面指向了一个必需功能————界面必须要随着数据的变化而自动更新

而围绕着这一核心诉求,出现了两条主流路径:

  • 后端动态生成HTML(传统的 MVC 模式): 数据在服务端组装成完整页面,再一次性返还给浏览器。
  • 前端接管界面更新(现代响应式范式): 后端只提供原始数据(如JSON API),而前端通过响应式系统来驱动视图自动同步。

而在这两条路背后,反映着前后端职责划分,同时也催生了以VueReact为代表的前端技术框架革命。

时代一:纯后端渲染 —— MVC 模式主导

假如有一个需求如:写一个简单的 HTTP 服务器,当用户访问 //users 路径时,返回一个包含用户列表的 HTML 页面,其他路径则返回 404 错误。

Node.js早期,如果我想实现这个需求,那么后端渲染将是不二之选。

代码示例:早期 Node.js 实现简单用户列表页

首先就是引入 Node.js 内置模块httpurl,而使用的方法则是Node.js最早的CommonJS 模块系统中的 require()来“导入”

const http = require("http"); // commonjs 
const url = require("url");   // url
  • http 模块:用于创建 HTTP 服务器(处理请求和响应)。
  • url 模块:用于解析浏览器发来的 URL 字符串(如 /users?id=1)。

然后再准备一些模拟数据

const users = [
    { id: 1, name: '张三', email: '123@qq.com' },
    { id: 2, name: '李四', email: '123456@qq.com' },
    { id: 3, name: '王五', email: '121@qq.com' }
]

接下来就要创建生成 HTML 页面的函数了

先使用.map()方法来动态生成表格行,对每个用户生成一行 HTML 表格,用反引号来插入变量,使用 .join('')来拼接所有行,最后返还一个完整的 HTML 文档。

function generateUsersHtml(users) {
    const userRows = users.map(user => `
        <tr>
            <td>${user.id}</td>
            <td>${user.name}</td>
            <td>${user.email}</td>
        </tr>
    `).join('');
    
    return `
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>User List</title>
        <style>
            table { width: 100%; border-collapse: collapse; margin-top: 20px; }
            th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
            th { background-color: #f4f4f4; }
        </style>
    </head>
    <body>
        <h1>Users</h1>
        <table>
            <thead>
                <tr>
                    <th>ID</th>
                    <th>Name</th>
                    <th>Email</th>
                </tr>
            </thead>
            <tbody>
                ${userRows}
            </tbody>
        </table>
    </body>
    </html>
    `;
}

最后也是最重要的就是创建 HTTP 服务器了。

const server = http.createServer((req, res) => {
    const parsedUrl = url.parse(req.url, true);
    
    if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users') {
        res.statusCode = 200;
        res.setHeader('Content-Type', 'text/html;charset=utf-8');
        const html = generateUsersHtml(users);
        res.end(html);
    } else {
        res.statusCode = 404;
        res.setHeader('Content-Type', "text/plain");
        res.end('Not Found');
    }
});

关键概念解释:

  • req(Request):用户的请求对象,包含 URL、方法、头信息等。
  • res(Response):你要返回给用户的内容,通过它设置状态码、头、正文。

解析 URL

// url.parse(pathname, query)
const parsedUrl = url.parse(req.url, true);
  • req.url即用户请求的 路径+参数部分

  • url.parse()将 URL 字符串“拆解”成结构化的对象,从而方便读取,其中:

    • pathname: 路径部分(如 /users
    • query: 查询参数(如 ?id=1就变成了{ id: '1' }),这里的true是用于判断你是否需要自动解析URL参数部分并转换为对象(通常为 true)

路由判断(简单路由)

if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users')

如果路径是根路径 / 或 /users,就显示用户列表,否则返回 404。

成功响应(200)

res.statusCode = 200; // 设置状态码为 200
res.setHeader('Content-Type', 'text/html;charset=utf-8');
const html = generateUsersHtml(users);
res.end(html);

通过.setHeader告诉浏览器:“我返回的是 HTML,用 UTF-8 编码”。

然后利用函数 generateUsersHtml(users),传入用户数据,最后调用res.end()生成 HTML 并发送。

错误响应(404 Not Found)

res.statusCode = 404;
res.setHeader('Content-Type', "text/plain");
res.end('Not Found');

状态码 404 表示“页面不存在”,如果产生错误则返还Not Found

注意:url.parse() 是旧 API,现代开发基本弃用


Node.js HTTP服务器启动的最后一步

server.listen(1234, () => {
    console.log('Server is running on port 1234')
})

让服务器监听 1234 端口(任意修改)。此时就可以在浏览器访问:http://localhost:1234http://localhost:1234/users,而访问其他路径(如 /about)会显示 “Not Found”。

效果图:

image.png

核心缺点 + 时代局限性:

  1. 前后端高度耦合,协作效率低下

HTML 结构、样式、JavaScript 逻辑全部硬编码在一个函数里,如果要修改表格样式等操作,就得修改这个函数,并且无法复用。

而这也几乎将前后端工程师捆绑起来了:

  • 前端工程师无法独立开发或调试 UI,必须依赖后端接口和模板
  • 后端工程师被迫处理本应属于前端范畴的展示逻辑

阻碍了团队协作,让前后端工程师的开发体验都极差。

  1. 用户体验受限,交互能力弱

页面完全由服务端生成,每次跳转或操作都需整页刷新,无法实现局部更新、动态加载、表单实时校验等现代 Web 交互,即使只是点击一个按钮,也要重新请求整个 HTML 文档。

时代二:转折点 AJAX 与前后端分离的诞生

在 2005 年之前,Web 应用基本是:用户点击 → 浏览器发请求 → 后端生成完整 HTML → 返回 → 整页刷新 ,导致用户每次交互都像“重新打开一个页面”,体验感大打折扣。

转折事件:Google Maps(2005)首次大规模使用 XMLHttpRequest(XHR)

  • 地图拖拽时不刷新页面
  • 动态加载新区域数据
  • 用户体验飞跃 → 行业震动

这就是 AJAX(Asynchronous JavaScript and XML) 范式的诞生—— “让网页像桌面应用一样流畅”

范式对比再深化

维度 后端渲染(传统) 前后端分离(AJAX 时代)
职责划分 后端一家独大 前端负责 UI/交互,后端负责数据/API
开发模式 全栈一人干 前后端并行开发
部署方式 服务端部署 HTML 前端静态资源(CDN),后端 API(独立服务)
用户体验 卡顿、白屏、跳转 流畅、局部更新、SPA雏形
技术栈 PHP/Java/Node + 模板引擎 HTML/CSS/JS + REST API

代码示例:

已经配置好的环境

在后端 backend 文件夹中包含一个存储用户数据的db.json文件:

{
    "users": [
        {
            "id": 1,
            "name": "张三",
            "email": "123@qq.com"
        },
        {
            "id": 2,
            "name": "李四",
            "email": "1232@qq.com"
        },
        {
            "id": 3,
            "name": "王五",
            "email": "121@qq.com"
        }
    ]
}

注:json-server 会把 JSON 的顶层 key(如 "users")自动映射为 RESTful 路由

package.json 中的脚本

{
  "scripts": {
    "dev": "json-server --watch db.json"
  }
}

就使得运行 npm run dev 时,json-server 会监听 backend/db.json 文件变化(--watch),并且启动一个 HTTP 服务器,默认端口 3000。(别忘了启动后端服务哦~~)

前端代码(重头戏):

基础页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>User List</title>
    <style>
        table { width: 100%; border-collapse: collapse; margin-top: 20px; }
        th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
        th { background-color: #f4f4f4; }
    </style>
</head>
<body>
    <h1>Users</h1>
    <table>
        <thead>
            <tr>
                <th>ID</th>
                <th>Name</th>
                <th>Email</th>
            </tr>
        </thead>
        <tbody>
        </tbody>
    </table>
</body>
</html>

<script>中的内部逻辑

<script>
    // DOM 编程
    fetch('http://localhost:3000/users') // 发出请求
        .then(res => res.json()) // 将 JSON 字符串转为 JS 对象数组
        .then(data => {
            const tbody = document.querySelector('tbody');
            tbody.innerHTML = data.map(user => `
                <tr>
                    <td>${user.id}</td>
                    <td>${user.name}</td>
                    <td>${user.email}</td>
                </tr>
            `).join('');
        })
</script>

.then(data => ...)中的 data 就是上一步 res.json() 解析的结果,并且通过 .map()生成字符串数组,再用.join('') 拼接字符串,而通过 tbody.innerHTML 让浏览器重新解析并渲染表格。

tongyi-mermaid-2025-12-11-165610.png

这代表了“纯手工”前后端分离的起点

  • 前端不再依赖后端吐 HTML
  • 数据通过 JSON API 获取
  • 视图由 JavaScript 动态生成

image.png

但这种方法并非完美,仍然存在痛点:手动操作 DOM 繁琐且易错

举个“胶水代码灾难”的例子:

// 用户点击“删除”
button.onclick = () => {
  fetch(`/api/users/${id}`, { method: 'DELETE' })
    .then(() => {
      // 从列表中移除元素
      li.remove();
      // 更新
      countSpan.textContent = --totalCount;
      // 如果列表空了,显示“暂无数据”
      if (totalCount === 0) emptyMsg.style.display = 'block';
      // 可能还要发埋点、更新缓存、通知其他组件...
    });
};

视图更新逻辑散落在各处,难以维护,极易出错,删除数据要:

  • 找到 <tr> 并删除
  • 更新
  • 找到空状态提示并显示
  • 可能还要:更新侧边栏统计、刷新分页、清除搜索高亮……

每次 UI 变化都要手动找一堆 DOM 节点去修改,并且难以复用。

这时期的前端程序员内心都憋着一句话:我不想再写 document.getElementById 了!

AJAX 让网页活了过来,但也让前端开发者陷入了新的地狱(DOM)——直到框架降临

时代三:革命!响应式数据驱动界面的崛起

核心思想:

“你只管改数据,界面自动更新。”

关键技术:ref 与响应式系统

  • ref() 将普通值包装成响应式对象
  • 模板中通过 {{ }}v-for 声明式绑定数据
  • 数据变化会自动触发视图更新(无需手动 DOM 操作)

响应式(以 Vue 为例)

<template>
  <table>
    <thead>
        <tr>
            <th>ID</th>
            <th>Name</th>
            <th>Email</th>
        </tr>
    </thead>
    <tbody>
      <!-- 遍历数据渲染到界面 -->
      <tr v-for="user in users" :key="user.id">
        <td>{{ user.id }}</td>
        <td>{{ user.name }}</td>
        <td>{{ user.email }}</td>
      </tr>
    </tbody>
  </table>
</template>
  • v-for:声明遍历 users 数组
  • {{ }} :显示 user 的某个属性
  • :key:帮助 Vue 高效追踪列表变化(性能优化)

但是关键在于:我没有写任何 DOM 操作代码!

只是在“描述 UI 应该是什么样子”,而不是“怎么去修改 DOM”。

<script setup>
import { 
  ref,
  onMounted // 挂载之后的生命周期
} from 'vue'; // 响应式api(将数据包装成响应式对象)

// 用 ref() 将普通数组包装成一个 响应式引用对象
const users = ref([]);

// onMounted:确保 DOM 已创建后再发起请求(避免操作不存在的元素)
onMounted(() => {
  console.log('页面挂载完成');
  fetch('http://localhost:3000/users')
    .then(res => res.json())
    .then(data => {
      users.value = data; // 只修改数据
    })
})

// 定时器添加数据
setTimeout(() => {
  users.value.push({
    id: '4',
    name: '钱六',
    email: '12313@qq.com'
  })
}, 3000)
</script>

没有 innerHTML没有 createElement没有 getElementById

并且所有 UI 更新都是数据变化的自然结果,无需人工干预!

2025-12-11.gif

整个历史进程:

阶段 开发模式 核心关注点 开发体验
后端渲染 MVC 数据 → 模板 → HTML 前端边缘化
前后端分离 AJAX + DOM 手动同步数据与视图 繁琐、易错
响应式框架 数据驱动 聚焦业务逻辑 高效、声明式、愉悦

这段短短的 Vue 代码,浓缩了前端开发十年的演进:

  • 从“操作 DOM”到“描述 UI”
  • 从“分散状态”到“单一数据源”
  • 从“易错胶水”到“自动同步”

它让前端开发者终于认识到一个新的自己:前端不再只是“切图仔”,而是复杂应用的架构者与体验设计师。 这,就是 响应式数据驱动界面 的革命性所在。

zero-admin后台管理模板

zero-admin 管理后台模板

zero-admin 是一个后台前端解决方案,它基于 vue3 和 ant-design-vue 实现。它使用了最新的前端技术栈【vue3+vue-router+typescript+axios+ant-design-vue+pinia+mockjs+plopjs+vite+Vitest】实现了动态路由、权限验证;自定义 Vue 指令封装;规范项目代码风格;项目内置脚手架解决文件创建混乱,相似业务模块需要频繁拷贝代码或文件问题;Echarts 图形库进行封装;axios 请求拦截封装,请求 api 统一管理;通过 mockjs 模拟数据;对生产环境构建进行打包优化,实现了打包 gzip 压缩、代码混淆,去除 console 打印,打包体积分析等;提炼了典型的业务模型,提供了丰富的功能组件,它可以帮助你快速搭建企业级中后台产品原型

image.png

image.png

image.png

推荐 VsCode 编辑器插件

VSCode + Volar (并禁用 Vetur 扩展插件) + TypeScript Vue Plugin (Volar).

TS 中.vue导入的类型支持

默认情况下,TypeScript 无法处理“.vue”导入的类型信息,因此我们将“tsc”CLI 替换为“vue-tsc”进行类型检查。在编辑中,我们需要 TypeScript Vue Plugin (Volar) 以使 TypeScript 语言服务了解“.vue”类型。

如果你觉得独立的 TypeScript 插件不够快,Volar 还实现了一个 Take Over Mode that 这更有表现力。您可以通过以下步骤启用它:

  1. Disable the built-in TypeScript Extension
    1. Run Extensions: Show Built-in Extensions from VSCode's command palette
    2. Find TypeScript and JavaScript Language Features, right click and select Disable (Workspace)
  2. Reload the VSCode window by running Developer: Reload Window from the command palette.

项目获取

git clone gitee.com/zmmlet/zero…

自定义 Vite 配置

Vite 配置参考.

项目依赖安装

pnpm install

开发环境运行(编译和热重新加载)

pnpm dev

打包部署运行(生产打包添加类型检查、编译)

pnpm build

运行单元测试 Vitest

pnpm test:unit

语法规则和代码风格检查 ESLint

pnpm lint

功能列表

  • 项目创建
  • 配置项目代码风格 .prettierrc.json
  • 配置.vscode setting.json 文件,配置保存格式化
  • 添加 SCSS 到项目进行 CSS 预处理
  • 配置 vscode 别名跳转规则
  • 安装 ant design vue 并配置自动加载
  • 配置 less 预处理,并自定义 ant Design Vue UI 主题
  • 解决 vite 首屏加载缓慢问题
  • pinia 数据持久化
  • 解决 pinia 使用报错问题
  • Layout 布局
  • Axios 封装
  • 菜单图标动态绑定
  • Vitest 单元测试
  • 集成打印插件
  • import.meta.glob 批量导入文件夹下文件
  • 配置 .env
  • 自定义按钮权限指令
  • 动态路由
  • 路由权限
  • 按钮权限(目前和登录账号有关,和具体页面无关)
  • Echarts 集成
  • 路由懒加载
  • 项目打包优化
    • 打包 gzip 压缩
    • 代码混淆
    • 去除生产环境 console
    • 打包体积分析插件
    • 代码拆包,将静态资源分类
    • 传统浏览器兼容性支持
    • CDN 内容分发网络(Content Delivery Network)
  • 项目集成自定义 cli 解决项目重复复制代码问题
  • 集成 mockjs
  • 读取 makdown 文档,编写组件说明文档
  • maptalks + threejs demo 示例
  • 使用 postcss-pxtorem、autoprefixer 插件 px 自动转换为 rem 和自动添加浏览器兼容前缀
  • Monorepo
  • 基于 sh 脚本对项目进行一键部署
  • 图形编辑器
    • 流程图
  • 国际化
  • CI/CD
  • 自动化部署
  • 使用 commitizen 规范 git 提交,存在 plopfile.js 和 commitlint 提交规范 导入模式问题"type": "module"冲突问题,导致目前 commitizen 规范 git 提交验证暂时不可用
  • husky

项目创建

  1. 项目创建命令 pnpm create vite
  2. 选择对应初始配置项
Progress: resolved 1, reused 1, downloaded 0, added 1, done
√ Project name: ... zero-admin
√ Select a framework: » Vue
√ Select a variant: » Customize with create-vue ↗
Packages: +1

Vue.js - The Progressive JavaScript Framework

√ Add TypeScript? ... No / Yes
√ Add JSX Support? ... No / Yes
√ Add Vue Router for Single Page Application development? ... No / Yes
√ Add Pinia for state management? ... No / Yes
√ Add Vitest for Unit Testing? ... No / Yes
√ Add an End-to-End Testing Solution? » No
√ Add ESLint for code quality? ... No / Yes
√ Add Prettier for code formatting? ... No / Yes

Scaffolding project in D:\learningSpace\code\vue-project\zero-admin...

Done. Now run:

cd zero-admin
pnpm install
pnpm lint
pnpm dev

初始项目依赖安装

  1. pnpm add ant-design-vue --save
  2. pnpm add unplugin-vue-components -D
  3. pnpm add axios
  4. pnpm add sass-loader@7.2.0 sass@1.22.10 -D
  5. pnpm add less -D

配置项目代码风格 .prettierrc.json

{
  "stylelintIntegration": true,
  "eslintIntegration": true,
  "printWidth": 80, //单行长度
  "tabWidth": 2, //缩进长度
  "useTabs": false, //使用空格代替tab缩进
  "semi": true, //句末使用分号
  "singleQuote": false, //使用单引号
  "endOfLine": "auto"
}

配置保存(Ctrl + s)自动格式化代码

在项目中创建.vscode 文件夹中创建 setting.json 文件

{
  "editor.codeActionsOnSave": {
    "source.fixAll": true
  },
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[vue]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  } // 默认格式化工具选择prettier
}

添加 SCSS 到项目进行 CSS 预处理

  1. pnpm add sass-loader@7.2.0 sass@1.22.10 -D

  2. 新建styles/scss 文件夹,新建 index.scss文件

  3. vite.config.ts 文件中配置

css: {
  preprocessorOptions: {
    // 配置 scss 预处理
    scss: {
      additionalData: '@import "@/style/scss/index.scss";',
    },
  },
},

项目根目录新建 jsconfig.json 文件

配置 vscode 别名跳转规则

{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "baseUrl": ".",
    "jsx": "react",
    "paths": {
      "@/*": ["./src/*"],
      "@components/*": ["./src/components/*"],
      "@setting/*": ["./src/setting/*"],
      "@views/*": ["./src/views/*"],
      "@assets/*": ["./src/assets/*"],
      "@config/*": ["./src/config/*"],
      "@api/*": ["./src/api/*"],
      "@utils/*": ["./src/utils/*"],
      "@styles/*": ["./src/styles/*"],
      "@store/*": ["./src/store/*"]
    }
  },
  "exclude": ["node_modules", "dist"]
}

vite.config.ts 文件中配置对应文件夹别名

resolve: {
  alias: {
    "@": fileURLToPath(new URL("./src", import.meta.url)),
    "@comp": path.resolve(__dirname, "./src/components"),
  },
  extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json", ".vue"],
},

安装 ant design vue 并配置自动加载

安装 UI 和自动加载插件 pnpm add ant-design-vue --save pnpm add unplugin-vue-components -D 在 vite.config.ts 引入配置

// 引入 ant design vue 按需加载
import Components from "unplugin-vue-components/vite";
import { AntDesignVueResolver } from "unplugin-vue-components/resolvers";
export default defineConfig({
  // 插件
  plugins: [
    vue(),
    // ant design vue 按需加载
    Components({
      resolvers: [AntDesignVueResolver({ importStyle: "less" })],
    }),
  ],
});

配置 less 预处理,并自定义 ant Design Vue UI 主题

安装 pnpm add less -D 在 vite.config.ts 引入配置

export default defineConfig({
  // 插件
  plugins: [
    vue(),
    // ant design vue 按需加载
    Components({
      resolvers: [AntDesignVueResolver({ importStyle: "less" })],
    }),
  ],

  css: {
    preprocessorOptions: {
      // 自定义 ant desing vue 主题样式
      less: {
        modifyVars: {
          "@primary-color": "red",
          "@border-radius-base": "0px", // 组件/浮层圆角
        },
        javascriptEnabled: true,
      },
    },
  },
});

pinia 数据持久化

pnpm add pinia-plugin-persist --save

解决 pinia 使用报错问题

使用

import { userStore } from "@/stores/modules/user";
const usersto = userStore();
console.log("store :>> ", usersto);

报错 转存失败,建议直接上传图片文件

解决方法

import store from "@/stores/index";
import { userStore } from "@/stores/modules/user";
const usersto = userStore(store);
console.log("store :>> ", usersto);

Layout 布局

Axios 封装

pnpm add axios --save

菜单图标动态绑定

  1. 动态创建
// ICON.ts
import { createVNode } from "vue";
import * as $Icon from "@ant-design/icons-vue";

export const Icon = (props: { icon: string }) => {
  const { icon } = props;
  return createVNode($Icon[icon]);
};
  1. 引入使用
<template>
  <div class="about">about <Icon :icon="icon" /></div>
</template>
<script lang="ts" setup>
import { Icon } from "@/setting/ICON";
import { ref } from "vue";
const icon = ref("AppstoreOutlined");
</script>

打包 gzip 压缩

// 引入 gzip 压缩
import viteCompression from "vite-plugin-compression";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueJsx(),
    // 打包压缩,主要是本地gzip,如果服务器配置压缩也可以
    viteCompression({
      verbose: true,
      disable: false,
      threshold: 10240,
      algorithm: "gzip",
      ext: ".gz",
    }),
  ],
});
server {
  #端口号,不同的程序,复制时,需要修改其端口号
        listen      3031;
  #服务器地址,可以为IP地址,本地程序时,可以设置为localhost
        server_name  localhost;
        client_max_body_size 2G;

    # 开启gzip
        gzip on;
    # 启用gzip压缩的最小文件,小于设置值的文件将不会压缩
        gzip_min_length 1k;
    # gzip 压缩级别,1-9,数字越大压缩的越好,也越占用CPU时间,后面会有详细说明
        gzip_comp_level 1;
    # 进行压缩的文件类型。javascript有多种形式。其中的值可以在 mime.types 文件中找到。
        gzip_types text/html text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png application/vnd.ms-fontobject font/ttf font/opentype font/x-woff image/svg+xml;
    # 是否在http header中添加Vary: Accept-Encoding,建议开启
        gzip_vary on;
    # 禁用IE 6 gzip
        gzip_disable "MSIE [1-6]\.";
    # 设置压缩所需要的缓冲区大小
        gzip_buffers 32 4k;
    # 设置gzip压缩针对的HTTP协议版本
        gzip_http_version 1.0;

  #程序所在目录
        root D:/learningSpace/code/vue-project/zero-admin/dist;
        charset utf-8;
            index index.html;

        location / {
            try_files $uri $uri/ /index.html;
        }

        location @rewrites {
            rewrite ^(.+)$ /index.html last;
        }

  #程序映射地址,将【zero-service】改为你程序名称,将【proxy_pass】 改为你自己的后台地址
        location /zero-service {
            proxy_pass http://localhost:9099/zero-service;
            proxy_cookie_path / /zero-service;
        }
    }

代码混淆

pnpm add terser -D

export default defineConfig({
  // 打包配置
  build: {
    chunkSizeWarningLimit: 500, // hunk 大小警告的限制(以 kbs 为单位)
    minify: "terser", // 代码混淆  boolean | 'terser' | 'esbuild' ,当设置为 'terser' 时必须先安装 Terser pnpm add terser -D
  },
});

去除生产环境 console

export default defineConfig({
  // 打包配置
  build: {
    terserOptions: {
      compress: {
        // warnings: false,
        drop_console: true, // 打包时删除console
        drop_debugger: true, // 打包时删除 debugger
        pure_funcs: ["console.log", "console.warn"],
      },
      output: {
        comments: true, // 去掉注释内容
      },
    },
  },
});

打包体积分析插件

  1. 安装 pnpm add rollup-plugin-visualizer -D
  2. vite.config.ts 配置

传统浏览器兼容性支持

  1. 安装 pnpm add @vitejs/plugin-legacy -D
  2. 在 vite.config.ts 中配置
import legacyPlugin from "@vitejs/plugin-legacy";
export default ({ command, mode }: ConfigEnv): UserConfig => {
  return {
    plugins: [
      legacyPlugin({
        targets: ["chrome 52"], // 需要兼容的目标列表,可以设置多个
        // additionalLegacyPolyfills: ["regenerator-runtime/runtime"], // 面向IE11时需要此插件
      }),
    ],
  };
};
  1. 添加传统浏览器兼容性支持,打包后在 dist 文件夹下 index.html 文件中确认 转存失败,建议直接上传图片文件

CDN 内容分发网络(Content Delivery Network)

  1. 插件安装pnpm add vite-plugin-cdn-import -D -w
  2. vite.config.ts 配置

Vitest 单元测试

vitest 参考文章:juejin.cn/post/714837…

  1. Vitest 测试已经在项目初始化的时候添加
  2. vue 组件测试 pnpm add @vue/test-utils -D
  3. 测试规则,添加查看 vite.config.ts 文件
  4. 编写 vue 组件
<template>
  <div>
    <div>Count: {{ count }}</div>
    <div>name: {{ props.name }}</div>
    <h2 class="msg">{{ msg }}</h2>
    <button @click="handle">点击事件</button>
  </div>
</template>

<script setup lang="ts">
import { onMounted, ref } from "vue";

const props = defineProps({
  name: {
    type: String,
    default: "1111",
  },
});

const count = ref<number>(0);

let msg = ref<string>("hello");

const handle = () => {
  count.value++;
};

onMounted(() => {
  console.log("props.message==", props.name);
});
</script>

<style scoped lang="scss"></style>
  1. 编写测试文件
import { test, expect } from "vitest";
import { mount } from "@vue/test-utils";
import Count from "../index.vue";

test.concurrent("基础js测试", () => {
  expect(1 + 1).toBe(2);
});

const Component = {
  template: "<div>44Hello world</div>",
};

// mount 的第二个参数,可以传一些配置项,比如props。这在测试组件时,很好用
test("mounts a component", () => {
  const wrapper = mount(Component, {});

  expect(wrapper.html()).toContain("Hello world");
});

// 测试 props 组件传参
test("测试 props 组件传参", () => {
  // 测试props 传参
  const wrapper = mount(Count, {
    props: {
      name: "Hello world",
    },
  });
  expect(wrapper.text()).toContain("Hello world");
  // 测试 ref指定初始值
  expect(wrapper.vm.count).toBe(0);
  // 测试点击事件
  const button = wrapper.find("button");
  button.trigger("click");
  expect(wrapper.vm.count).toBe(1);
  // 测试msg渲染
  expect(wrapper.find(".msg").text()).toBe("hello");
});
  1. 安装 vscode 插件,配置测试 debug 环境

    • 插件商店搜索 Vitest 安装
    • 点击 debug 选择 node 配置 .vscode 文件夹下 launch.json 文件
    {
      // Use IntelliSense to learn about possible attributes.
      // Hover to view descriptions of existing attributes.
      // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
      "version": "0.2.0",
      "configurations": [
        {
          "type": "node",
          "request": "launch",
          "name": "Debug Current Test File",
          "autoAttachChildProcesses": true,
          "skipFiles": ["<node_internals>/**", "**/node_modules/**"],
          "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
          "args": ["run", "${relativeFile}"],
          "smartStep": true,
          "console": "integratedTerminal"
        }
      ]
    }
    
  2. 点击 debug 运行 转存失败,建议直接上传图片文件转存失败,建议直接上传图片文件

集成打印插件

  1. 官网:printjs.crabbly.com/#documentat…
  2. 安装 pnpm add print-js

配置 .env

  1. vite.config.ts 读取
const root = process.cwd();
const env = loadEnv(process.argv[process.argv.length - 1], root);
// 读取值 env.VITE_APP_SERVICE;
  1. ts 文件读取import.meta.env.VITE_APP_TITLE

import.meta.glob 批量导入文件夹下文件

function addRouter(list) {
  const modules = import.meta.glob("./views/**.vue");
  for (const path in modules) {
    modules[path]().then((mod) => {
      const file = mod.default;
      if (list.map((a) => a.name).includes(file.name)) {
        router.addRoute({
          path: "/" + file.name,
          name: file.name,
          component: file,
        });
      }
    });
  }
}

解决 vite 首屏加载缓慢问题

参考文章:

采用判断是否为生产环境,生产环境打包,自动加载,开发环境全局引入,修改 vite.config.ts 文件

export default ({ command, mode }: ConfigEnv): UserConfig => {
  // 读取环境变量配置
  const root = process.cwd();
  const env = loadEnv(process.argv[process.argv.length - 1], root);
  // 判断是否为打包环境
  const isBuild = command === "build";

  return {
    plugins: [
      vue(),
      vueJsx(),
      // ant design vue 按需加载
      Components({
        resolvers: [
          AntDesignVueResolver({ importStyle: isBuild ? "less" : false }),
        ],
      }),
    ],
  };
};

全局引入在 main.ts 文件中,目前未实现环境变量的判断,如需打包,请手动注释掉全局引入的 ant-design-vue 样式

// 生产环境下,注释掉下面的全局样式引入
import "ant-design-vue/dist/antd.less";

利用 plop,自定义脚手架

Plop 是一个小而美的脚手架工具,它主要用于创建项目中特定类型的文件,Plop 主要集成在项目中使用,帮助我们快速生成一定规范的初始模板文件

  1. 安装 pnpm add plop -D
  2. 在项目根目录下创建 plopfile.js 文件
#!/usr/bin/env node
import componentsSetting from "./plop-templates/components/prompt.js";
import pageSetting from "./plop-templates/pages/prompt.js";

export default function (plop) {
  plop.setWelcomeMessage("请选择需要创建的模式:");
  plop.setGenerator("components", componentsSetting);
  plop.setGenerator("page", pageSetting);
}
  1. 在项目根目录下创建plop-templates文件夹

    • 新建 components 文件夹,添加文件prompt.js指令文件和index.hbs模板文件
    • prompt.js 指令内容
    import fs from "fs";
    function getFolder(path) {
      const components = [];
      const files = fs.readdirSync(path);
      files.forEach((item) => {
        const stat = fs.lstatSync(`${path}/${item}`);
        if (stat.isDirectory() === true && item !== "components") {
          components.push(`${path}/${item}`);
          components.push(...getFolder(`${path}/${item}`));
        }
      });
      return components;
    }
    
    const componentsSetting = {
      description: "创建组件",
      // 提示数组
      prompts: [
        {
          type: "confirm",
          name: "isGlobal",
          message: "是否为全局组件",
          default: false,
        },
        {
          type: "list",
          name: "path",
          message: "请选择组件创建目录",
          choices: getFolder("src/components"),
          when: (answers) => {
            return !answers.isGlobal;
          },
        },
        {
          type: "input",
          name: "name",
          message: "请输入组件名称",
          validate: (v) => {
            if (!v || v.trim === "") {
              return "组件名称不能为空";
            } else {
              return true;
            }
          },
        },
      ],
      // 行为数组
      actions: (data) => {
        let path = "";
        if (data.isGlobal) {
          path = "src/components/{{properCase name}}/index.vue";
        } else {
          path = `${data.path}/components/{{properCase name}}/index.vue`;
        }
        const actions = [
          {
            type: "add",
            path,
            templateFile: "plop-templates/components/index.hbs",
          },
        ];
        return actions;
      },
    };
    
    export default componentsSetting;
    
    • index.hbs 模板内容
    <template>
      <div>
        <!-- 布局 -->
      </div>
    </template>
    
    <script lang="ts" setup{{#if isGlobal}} name="{{ properCase name }}"{{/if}}>
    // 逻辑代码
    </script>
    
    <style lang="scss" scoped>
    // 样式
    </style>
    
  2. 在项目 package.json 添加 "cli": "plop", 命令


"scripts": {
    "cli": "plop",
  },
  1. 通过 pnpm cli 选择创建项目代码模板 转存失败,建议直接上传图片文件

集成 mockjs 模拟后台接口

  1. 安装依赖

    • pnpm add mockjs
    • pnpm add @types/mockjs -D
    • pnpm add vite-plugin-mock -D
  2. 配置 vite.config.ts 文件

    • 引入插件 import { viteMockServe } from "vite-plugin-mock";
    • 在 数组中进行配置
    export default ({ command, mode }: ConfigEnv): UserConfig => {
      // 读取环境变量配置
      const root = process.cwd();
      const env = loadEnv(process.argv[process.argv.length - 1], root);
    
      const isBuild = command === "build";
    
      return {
        plugins: [
          //....
          viteMockServe({
            mockPath: "src/mock",
            localEnabled: !isBuild,
            prodEnabled: isBuild,
            injectCode: `
          import { setupProdMockServer } from './mockProdServer';
          setupProdMockServer();
          `,
          }),
        ],
      };
    };
    
  3. 新建 src\mockProdServer.ts 文件与 main.ts 文件同级

import { createProdMockServer } from "vite-plugin-mock/es/createProdMockServer";

const mocks: any[] = [];
const mockContext = import.meta.glob("./mock/*.ts", {
  eager: true,
});
Object.keys(mockContext).forEach((v) => {
  mocks.push(...(mockContext[v] as any).default);
});

export function setupProdMockServer() {
  createProdMockServer(mocks);
}
  1. 新建 src\mock 文件夹,mock 文件下,新建业务模块 login.ts
export default [
  {
    url: "/api/sys/login", // 模拟登录接口
    method: "POST", // 请求方式
    timeout: 3000, // 超时事件
    statusCode: 200, // 返回的http状态码
    response: (option: any) => {
      // 返回的结果集
      return {
        code: 200,
        message: "登录成功",
        data: {
          failure_time: Math.ceil(new Date().getTime() / 1000) + 24 * 60 * 60,
          account: option.body.account,
          token: "@string",
        },
      };
    },
  },
];
  1. 利用封装的 api 调用 /api/sys/login 接口
postAction("/sys/login", { userName: userName, password: password }).then(
  (res: any) => {}
);

自定义按钮权限指令

  1. 在 directive 文件夹下,新建 permission.ts 文件。添加权限指令代码
// 引入vue中定义的指令对应的类型定义
import type { Directive } from "vue";
const permission: Directive = {
  // mounted是指令的一个生命周期
  mounted(el, binding) {
    // value 获取用户使用自定义指令绑定的内容
    const { value } = binding;
    // 获取用户所有的权限按钮
    // const permissionBtn: any = sessionStorage.getItem("permission");
    const permissionBtn: any = ["admin", "dashboard.admin"];
    // 判断用户使用自定义指令,是否使用正确了
    if (value && value instanceof Array && value.length > 0) {
      const permissionFunc = value;
      //判断传递进来的按钮权限,用户是否拥有
      //Array.some(), 数组中有一个结果是true返回true,剩下的元素不会再检测
      const hasPermission = permissionBtn.some((role: any) => {
        return permissionFunc.includes(role);
      });
      // 当用户没有这个按钮权限时,返回false,使用自定义指令的钩子函数,操作dom元素删除该节点
      if (!hasPermission) {
        // el.style.display = "none";
        el.parentNode && el.parentNode.removeChild(el);
      }
    } else {
      throw new Error(`传入关于权限的数组,如 v-permission="['admin','user']"`);
    }
  },
};

export default permission;
  1. 在 directive 文件夹下,新建 index.ts 批量注册指令
import type { Directive } from "vue";
import permission from "./permission";
// 自定义指令
const directives = { permission };

export default {
  install(app: any) {
    Object.keys(directives).forEach((key) => {
      // Object.keys() 返回一个数组,值是所有可遍历属性的key名
      app.directive(key, (directives as { [key: string]: Directive })[key]); //key是自定义指令名字;后面应该是自定义指令的值,值类型是string
    });
  },
};
  1. 在 main.ts 文件引入,注册自定义指令
import { createApp } from "vue";
import App from "./App.vue";
import directive from "./directive";

const app = createApp(App);
app.use(directive);

app.mount("#app");

处理 px 转 rem,和 css 自动添加浏览器前缀

  1. 安装pnpm add postcss-pxtorem autoprefixer -D
  2. vite.config.ts 配置
import postCssPxToRem from "postcss-pxtorem";
import autoprefixer from "autoprefixer";
export default ({ command, mode }: ConfigEnv): UserConfig => {
  return {
    css: {
      postcss: {
        plugins: [
          postCssPxToRem({
            // 自适应,px>rem转换
            rootValue: 16, // 1rem的大小
            propList: ["*"], // 需要转换的属性,这里选择全部都进行转换
          }),
          autoprefixer({
            // 自动添加前缀
            overrideBrowserslist: [
              "Android 4.1",
              "iOS 7.1",
              "Chrome > 31",
              "ff > 31",
              "ie >= 8",
              //'last 2 versions', // 所有主流浏览器最近2个版本
            ],
            grid: true,
          }),
        ],
      },
    },
  };
};

Monorepo

Monorepo 是一种项目管理方式,就是把多个项目放在一个仓库里面 juejin.cn/post/696432…

  1. 项目根目录新建 pnpm-workspace.yaml 文件
packages:
  # all packages in subdirs of packages/ and components/
  - "packages/**"
  1. @zero-admin/utils 安装 到 @zero-admin/chart 执行命令pnpm i @zero-admin/utils -r --filter @zero-admin/chart
  2. @zero-admin/chart 安装到根项目 package.json 文件中,执行命令 pnpm i @zero-admin/chart -w

图像编辑器

流程图

安装依赖
  1. 流程图核心包pnpm add @logicflow/core -w
  2. 流程图扩展包pnpm add @logicflow/extension -w
  3. 格式化展示 json 数据 pnpm add vue-json-pretty -w
初始化容器及 LogicFlow 对象
准备容器

国际化

  1. 安装pnpm add vue-i18n

读取 makdown 文档,编写组件说明文档

  1. 安装依赖 pnpm add @kangc/v-md-editor@next -D pnpm add prismjs -S pnpm add @types/prismjs -D
  2. 在 setting 文件夹下新建 mdEditor.ts 文件
import VueMarkdownEditor from "@kangc/v-md-editor";
import "@kangc/v-md-editor/lib/style/base-editor.css";
import vuepressTheme from "@kangc/v-md-editor/lib/theme/vuepress.js";
import "@kangc/v-md-editor/lib/theme/style/vuepress.css";

import Prism from "prismjs";

VueMarkdownEditor.use(vuepressTheme, {
  Prism,
});
export default VueMarkdownEditor;
  1. 在 main.ts 文件中引入挂载
import { createApp } from "vue";
import App from "./App.vue";

import VueMarkdownEditor from "@/setting/mdEditor";
const app = createApp(App);

app.use(VueMarkdownEditor);
app.mount("#app");
  1. 在组件中使用
<template>
  <v-md-editor
    v-model="markdownTable"
    height="calc(100vh - 293px)"
    mode="preview"
  ></v-md-editor>
</template>
<script setup lang="ts">
  import markdownTable from "./README.md?raw";
</script>

将资源引入为字符串:资源可以使用 ?raw 后缀声明作为字符串引入 官网:ckang1229.gitee.io/vue-markdow…

maptalks + threejs demo 示例

  1. 项目依赖 pnpm add three maptalks maptalks.three --save
  2. Demo 源码文件:文件路径: src\views\charts\smartCity.vue
  3. 访问 Demo
    • 启动项目 pnpm dev
    • 浏览器访问路径 http://localhost:3030/city

使用 commitizen 规范 git 提交

  1. 安装依赖 pnpm install commitizen @commitlint/config-conventional @commitlint/cli commitlint-config-cz cz-git -D

  2. 配置 package.json

{
  ...
  "scripts": {
    "git:comment": "引导设置规范化的提交信息",
    "git": "git pull && git add . && git-cz && git push",
  },

  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "lint-staged": {
    "src/**/*.{js,ts,vue}": [
      "prettier --write --ignore-unknown --no-error-on-unmatched-pattern",
      "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
    ],
    "package.json": [
      "prettier --write"
    ]
  },
  "config": {
    "commitizen": {
      "path": "node_modules/cz-git"
    }
  }
  ...
}
  1. 项目根目录新建 commitlint.config.js 添加配置
module.exports = {
  // 继承的规则
  extends: ["@commitlint/config-conventional", "cz"],
  // 定义规则类型
  rules: {
    // type 类型定义,表示 git 提交的 type 必须在以下类型范围内
    "type-enum": [
      2,
      "always",
      [
        "feature", // 新功能(feature)
        "bug", // 此项特别针对bug号,用于向测试反馈bug列表的bug修改情况
        "fix", // 修补bug
        "ui", // 更新 ui
        "docs", // 文档(documentation)
        "style", // 格式(不影响代码运行的变动)
        "perf", // 性能优化
        "release", // 发布
        "deploy", // 部署
        "refactor", // 重构(即不是新增功能,也不是修改bug的代码变动)
        "test", // 增加测试
        "chore", // 构建过程或辅助工具的变动
        "revert", // feat(pencil): add ‘graphiteWidth’ option (撤销之前的commit)
        "merge", // 合并分支, 例如: merge(前端页面): feature-xxxx修改线程地址
        "build", // 打包
      ],
    ],
    // <type> 格式 小写
    "type-case": [2, "always", "lower-case"],
    // <type> 不能为空
    "type-empty": [2, "never"],
    // <scope> 范围不能为空
    "scope-empty": [2, "never"],
    // <scope> 范围格式
    "scope-case": [0],
    // <subject> 主要 message 不能为空
    "subject-empty": [2, "never"],
    // <subject> 以什么为结束标志,禁用
    "subject-full-stop": [0, "never"],
    // <subject> 格式,禁用
    "subject-case": [0, "never"],
    // <body> 以空行开头
    "body-leading-blank": [1, "always"],
    "header-max-length": [0, "always", 72],
  },
  prompt: {
    alias: { fd: "docs: fix typos" },
    messages: {
      type: "选择你要提交的类型 :",
      scope: "选择一个提交范围(可选):",
      customScope: "请输入自定义的提交范围 :",
      subject: "填写简短精炼的变更描述 :\n",
      body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n',
      breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n',
      footerPrefixesSelect: "选择关联issue前缀(可选):",
      customFooterPrefix: "输入自定义issue前缀 :",
      footer: "列举关联issue (可选) 例如: #31, #I3244 :\n",
      confirmCommit: "是否提交或修改commit ?(y/n/e/h)",
    },
    types: [
      { value: "feat", name: "feat:     新增功能 | A new feature" },
      { value: "fix", name: "fix:      修复缺陷 | A bug fix" },
      {
        value: "docs",
        name: "docs:     文档更新 | Documentation only changes",
      },
      {
        value: "style",
        name: "style:    代码格式 | Changes that do not affect the meaning of the code",
      },
      {
        value: "refactor",
        name: "refactor: 代码重构 | A code change that neither fixes a bug nor adds a feature",
      },
      {
        value: "perf",
        name: "perf:     性能提升 | A code change that improves performance",
      },
      {
        value: "test",
        name: "test:     测试相关 | Adding missing tests or correcting existing tests",
      },
      {
        value: "build",
        name: "build:    构建相关 | Changes that affect the build system or external dependencies",
      },
      {
        value: "ci",
        name: "ci:       持续集成 | Changes to our CI configuration files and scripts",
      },
      { value: "revert", name: "revert:   回退代码 | Revert to a commit" },
      {
        value: "chore",
        name: "chore:    其他修改 | Other changes that do not modify src or test files",
      },
    ],
    allowCustomScopes: true,
    skipQuestions: ["body", "footer"],
  },
};

Git hooks 工具

vue3 使用 husky + commitlint 强制码提交规范

  1. 安装依赖 pnpm add lint-staged husky -D -w
  2. 添加 package.json 脚本
"prepare": "husky install"
  1. 初始化 husky 将 git hooks 钩子交由 husky 执行pnpm run prepare
  2. npx husky add .husky/pre-commit "pnpm run eslint"
  3. pnpm husky add .husky/commit-msg 'pnpm commitlint --edit $1'

git 使用命令

  1. 克隆远程仓库代码 git clone https://gitee.com/zmmlet/zero-admin.git

  2. 第 1 步:同步远程仓库代码:git pull git add / git commit 代码之前首先 git pull,需先从服务器上面拉取代码,以防覆盖别人代码;如果有冲突,先备份自己的代码,git checkout 下远程库里最新的的代码,将自己的代码合并进去,然后再提交代码。

  3. 第 2 步:查看当前状态:git status 使用 git status 来查看当前状态,红色的字体显示的就是你修改的文件

  4. 第 3 步:提交代码到本地 git 缓存区:git add 情形一:如果你 git status 查看了当前状态发现都是你修改过的文件,都要提交,那么你可以直接使用 git add . 就可以把你的内容全部添加到本地 git 缓存区中 情形二:如果你 git status 查看了当前状态发现有部分文件你不想提交,那么就使用 git add xxx(上图中的红色文字的文件链接) 就可以提交部分文件到本地 git 缓存区。

  5. 第 4 步:推送代码到本地 git 库:git commit git commit -m "提交代码" 推送修改到本地 git 库中

  6. 第 5 步:提交本地代码到远程仓库:git push git push <远程主机名> <远程分支名> 把当前提交到 git 本地仓库的代码推送到远程主机的某个远程分之上

技术栈

Vue 3 统一面包屑导航系统:从配置地狱到单一数据源

本文分享我们在 Vue 3 + TypeScript 项目中重构面包屑导航系统的实践经验,通过将面包屑配置迁移到路由 meta 中,实现了配置的单一数据源,大幅降低了维护成本。

一、问题背景

1.1 原有架构的痛点

在重构之前,我们的面包屑系统采用独立的配置文件 breadcrumb.ts,存在以下问题:

// 旧方案:独立的面包屑配置文件(700+ 行)
export const breadcrumbConfigs: BreadcrumbItemConfig[] = [
  {
    path: '/export/booking',
    name: 'BookingManage',
    title: '订舱管理',
    showInBreadcrumb: true,
    children: [
      { path: '/export/booking/create', name: 'BookingCreate', title: '新建订舱' },
      { path: '/export/booking/edit', name: 'BookingEdit', title: '编辑订舱' },
      // ... 更多子页面
    ],
  },
  // ... 几十个类似的配置
]

主要痛点:

  1. 配置分散:路由定义在 router/modules/*.ts,面包屑配置在 config/breadcrumb.ts,新增页面需要修改两处
  2. 维护成本高:配置文件超过 700 行,嵌套结构复杂,容易出错
  3. 同步困难:路由变更后容易忘记更新面包屑配置,导致显示异常
  4. 类型安全差:配置与路由之间缺乏类型关联

1.2 期望目标

  • 单一数据源:面包屑配置与路由定义合并,一处修改全局生效
  • 类型安全:利用 TypeScript 确保配置正确性
  • 易于维护:新增页面只需在路由配置中添加一行
  • 向后兼容:平滑迁移,不影响现有功能

二、技术方案

2.1 核心思路

将面包屑路径配置到路由的 meta 字段中,通过 Composable 自动解析生成面包屑导航。

路由配置 (meta.breadcrumb) → useBreadcrumb() → BreadCrumb.vue

2.2 扩展路由 Meta 类型

首先,扩展 Vue Router 的 RouteMeta 接口:

// src/router/types.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    /** 页面标题 */
    title?: string
    /** 国际化 key */
    i18nKey?: string
    /** 面包屑路径(路由名称数组) */
    breadcrumb?: string[]
    /** 是否缓存 */
    keepAlive?: boolean
    // ... 其他字段
  }
}

/** 面包屑项类型 */
export interface BreadcrumbItem {
  title: string
  path: string
  name: string
  i18nKey?: string
  isClickable: boolean
}

2.3 路由配置示例

在路由模块中添加 breadcrumb meta:

// src/router/modules/export.ts
export const exportRoutes: RouteRecordRaw[] = [
  {
    path: '/export/booking',
    name: 'BookingManage',
    component: () => import('~/views/export/booking/index.vue'),
    meta: {
      title: '订舱管理',
      keepAlive: true,
      breadcrumb: ['Export', 'BookingManage'], // 出口 > 订舱管理
    },
  },
  {
    path: '/export/booking/create/:mode',
    name: 'BookingCreate',
    component: () => import('~/views/export/booking/create.vue'),
    meta: {
      title: '新建订舱',
      breadcrumb: ['Export', 'BookingManage', 'BookingCreate'], // 出口 > 订舱管理 > 新建订舱
    },
  },
]

配置规则:

  • 数组元素为路由名称(name)或虚拟节点名称
  • 按层级顺序排列:[一级菜单, 二级菜单, 当前页面]
  • 空数组 [] 表示不显示面包屑(如首页)

2.4 useBreadcrumb Composable

核心逻辑封装在 Composable 中:

// src/composables/useBreadcrumb.ts
import type { BreadcrumbItem } from '~/router/types'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'

/**
 * 虚拟路由配置(菜单分类节点)
 * 这些节点在路由系统中不存在,但需要在面包屑中显示
 */
const VIRTUAL_ROUTES: Record<string, { title: string, i18nKey: string }> = {
  Mine: { title: '我的', i18nKey: 'mine' },
  Export: { title: '出口', i18nKey: 'export' },
  Import: { title: '进口', i18nKey: 'import' },
  Finance: { title: '财务', i18nKey: 'finance' },
  BoxManage: { title: '箱管', i18nKey: 'boxManage' },
}

export function useBreadcrumb() {
  const route = useRoute()
  const router = useRouter()
  const { t } = useI18n()

  /** 根据路由名称获取路由信息 */
  function getRouteByName(name: string) {
    return router.getRoutes().find(r => r.name === name)
  }

  /** 获取面包屑项的标题(支持国际化) */
  function getTitle(name: string, routeRecord?: RouteRecordNormalized): string {
    // 优先使用虚拟路由配置
    if (VIRTUAL_ROUTES[name]) {
      return t(`system.routes.${VIRTUAL_ROUTES[name].i18nKey}`, VIRTUAL_ROUTES[name].title)
    }
    // 使用路由 meta 配置
    if (routeRecord?.meta?.i18nKey) {
      return t(`system.routes.${routeRecord.meta.i18nKey}`, routeRecord.meta.title || name)
    }
    return routeRecord?.meta?.title || name
  }

  /** 计算面包屑列表 */
  const breadcrumbs = computed<BreadcrumbItem[]>(() => {
    const routeName = route.name as string
    if (!routeName) return []

    // 从路由 meta 获取面包屑配置
    const breadcrumbPath = route.meta?.breadcrumb as string[]
    if (!breadcrumbPath || breadcrumbPath.length === 0) {
      return []
    }

    // 构建面包屑列表
    return breadcrumbPath.map((name, index) => {
      const routeRecord = getRouteByName(name)
      const isLast = index === breadcrumbPath.length - 1
      const isVirtual = !!VIRTUAL_ROUTES[name]

      return {
        title: getTitle(name, routeRecord),
        path: isLast ? route.path : (routeRecord?.path || ''),
        name,
        i18nKey: isVirtual ? VIRTUAL_ROUTES[name].i18nKey : routeRecord?.meta?.i18nKey,
        isClickable: !isLast && !isVirtual && !!routeRecord,
      }
    })
  })

  /** 是否应该显示面包屑 */
  const shouldShow = computed<boolean>(() => {
    // 首页、登录页等不显示面包屑
    const hiddenPaths = ['/', '/index', '/dashboard', '/login', '/register']
    if (hiddenPaths.includes(route.path)) {
      return false
    }
    return breadcrumbs.value.length > 0
  })

  return { breadcrumbs, shouldShow }
}

2.5 面包屑组件

组件只需调用 Composable 即可:

<!-- src/layout/components/BreadCrumb/BreadCrumb.vue -->
<script setup lang="ts">
import { useBreadcrumb } from '~/composables'

const { breadcrumbs, shouldShow } = useBreadcrumb()
</script>

<template>
  <el-breadcrumb v-if="shouldShow" separator="/">
    <el-breadcrumb-item
      v-for="item in breadcrumbs"
      :key="item.name"
      :to="item.isClickable ? item.path : undefined"
    >
      {{ item.title }}
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>

三、迁移策略

3.1 渐进式迁移

为了确保平滑过渡,我们采用渐进式迁移策略:

  1. 阶段一:新增 useBreadcrumb Composable,支持从路由 meta 读取配置
  2. 阶段二:逐个模块添加 breadcrumb meta 字段
  3. 阶段三:验证所有页面面包屑正常后,删除旧配置文件

3.2 迁移清单

模块 文件 页面数
核心页面 core.ts 4
用户管理 user.ts 3
查询服务 search_service.ts 6
出口业务 export.ts 25+
进口业务 import.ts 4
财务结算 payment_settlement.ts 6
箱管业务 equipment-control.ts 12

3.3 国际化配置

确保所有菜单分类节点都有对应的国际化配置:

// src/i18n/zh/system.ts
export default {
  routes: {
    // 菜单分类节点
    mine: '我的',
    export: '出口',
    import: '进口',
    finance: '财务',
    boxManage: '箱管',
    
    // 具体页面
    bookingManage: '订舱管理',
    bookingCreate: '新建订舱',
    // ...
  },
}

四、效果对比

4.1 代码量对比

指标 重构前 重构后 变化
配置文件行数 737 行 0 行(已删除) -100%
新增页面修改文件数 2 个 1 个 -50%
类型安全

4.2 新增页面对比

重构前:

// 1. 修改路由配置
{ path: '/new-page', name: 'NewPage', component: ... }

// 2. 修改面包屑配置(容易遗漏!)
{ path: '/new-page', name: 'NewPage', title: '新页面', ... }

重构后:

// 只需修改路由配置
{
  path: '/new-page',
  name: 'NewPage',
  component: ...,
  meta: {
    title: '新页面',
    breadcrumb: ['ParentMenu', 'NewPage'],
  },
}

五、最佳实践

5.1 面包屑配置规范

// ✅ 推荐:使用路由名称数组
breadcrumb: ['Export', 'BookingManage', 'BookingCreate']

// ❌ 避免:使用路径
breadcrumb: ['/export', '/export/booking', '/export/booking/create']

5.2 虚拟节点使用场景

当菜单分类本身不是一个可访问的页面时,使用虚拟节点:

// "出口" 是菜单分类,不是实际页面
const VIRTUAL_ROUTES = {
  Export: { title: '出口', i18nKey: 'export' },
}

// 路由配置
breadcrumb: ['Export', 'BookingManage'] // 出口 > 订舱管理

5.3 动态路由处理

对于带参数的动态路由,Composable 会自动使用当前路由的完整路径:

// 路由定义
{ path: '/export/lading/edit/:id', name: 'BookingLadingEdit', ... }

// 面包屑配置
breadcrumb: ['Export', 'BookingLadingManagement', 'BookingLadingEdit']

// 实际显示:出口 > 提单管理 > 编辑提单
// 最后一项路径:/export/lading/edit/123(保留实际 ID)

六、总结

通过将面包屑配置迁移到路由 meta 中,我们实现了:

  1. 单一数据源:路由配置即面包屑配置,消除了配置分散的问题
  2. 维护成本降低:删除了 700+ 行的独立配置文件
  3. 开发效率提升:新增页面只需修改一处
  4. 类型安全增强:TypeScript 类型检查确保配置正确性
  5. 国际化支持:无缝集成 vue-i18n

这种方案特别适合中大型 Vue 3 项目,尤其是菜单结构复杂、页面数量多的企业级应用。


相关技术栈:

  • Vue 3.5+ (Composition API)
  • Vue Router 4
  • TypeScript 5+
  • vue-i18n

参考资料:

vue v-for列表渲染, 无key、key为index 、 有唯一key三种情况下的对比。 列表有删除操作时的表现

在 Vue3 的 v-for 列表渲染中,key 的使用方式直接影响列表更新时的 DOM 行为,尤其是包含删除操作时,不同 key 策略会呈现不同的表现(甚至异常)。下面从「无 key」「key 为 index」「key 为唯一值」三种场景逐一分析,并结合删除操作的示例说明差异。

核心原理铺垫

Vue 的虚拟 DOM 对比(diff 算法)依赖 key 来识别节点的唯一性:

  • 有唯一 key:Vue 能精准判断节点的增 / 删 / 移,只更新变化的 DOM;
  • 无 key/key 为 index:Vue 无法识别节点唯一性,会通过「就地复用」策略更新 DOM,可能导致 DOM 与数据不匹配。

场景复现准备

先定义基础组件,包含一个列表和删除按钮,后续仅修改 v-for 的 key

<template>
  <div>
    <div v-for="(item, index) in list" :key="xxx"> <!-- 重点:xxx 替换为不同值 -->
      <input type="text" v-model="item.name">
      <button @click="deleteItem(index)">删除</button>
    </div>
  </div>
</template>

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

// 初始化列表(每个项有唯一 id,模拟业务场景)
const list = ref([
  { id: 1, name: '张三' },
  { id: 2, name: '李四' },
  { id: 3, name: '王五' }
])

// 删除方法
const deleteItem = (index) => {
  list.value.splice(index, 1)
}
</script>

场景 1:无 key(不写 :key)

表现

删除某一项后,输入框的内容会「错位」,DOM 看似更新但数据与视图不匹配。

示例过程

  1. 初始状态:输入框分别输入「张三」「李四」「王五」;
  2. 删除索引 1(李四);
  3. 结果:列表只剩两项,但输入框显示「张三」「王五」→ 看似正常?✘ 实际异常点:若列表项包含「状态绑定 / 组件实例」(比如输入框焦点、自定义组件内部状态),会出现错位。(补充:无 key 时 Vue 会按「节点位置」复用 DOM,删除索引 1 后,原索引 2 的 DOM 会被移到索引 1 位置,仅更新文本内容,但组件 / 输入框的内部状态会保留。)

本质

Vue 认为「节点位置」即唯一标识,直接复用 DOM 节点,仅更新节点的文本 / 属性,忽略数据的唯一性,若列表项有「非响应式状态」(如输入框焦点、组件内部变量),会导致状态错位。

场景 2:key 为 index(:key="index")

表现

删除操作后,输入框内容错位更明显(比无 key 更易复现),是日常开发中最易踩的坑。

示例过程

  1. 初始状态:输入框分别输入「张三」「李四」「王五」;

  2. 删除索引 1(李四);

  3. 结果:

    • 数据层面:list 变为 [{id:1,name:'张三'}, {id:3,name:'王五'}]
    • 视图层面:输入框显示「张三」「李四」(而非「王五」),DOM 与数据完全错位。

原因分析(关键)

操作前 操作后(删除索引 1)
索引 0 → key0 → 张三 索引 0 → key0 → 张三(复用原 DOM,无变化)
索引 1 → key1 → 李四 索引 1 → key1 → 王五(复用原索引 1 的 DOM,仅更新文本,但输入框的 v-model 绑定的是 item.name,为何错位?)
索引 2 → key2 → 王五 索引 2 被删除

核心错位逻辑:当 key 为 index 时,删除索引 1 后,原索引 2 的项(id:3,name: 王五)会「占据」索引 1 的位置。Vue 的 diff 算法认为:

  • key0(索引 0)的节点不变,复用;
  • key1(索引 1)的节点需要更新,于是将原索引 1 的 DOM 节点的 item 替换为新的索引 1 项(王五),但输入框的 DOM 节点是复用的,v-model 的绑定是「事后更新」,导致视觉上输入框内容未同步(或出现延迟 / 错位)。

极端案例(含组件状态)

若列表项是自定义组件(有内部状态):

<!-- 自定义组件 -->
<template>
  <div>{{ item.name }} - 内部状态:{{ innerState }}</div>
</template>
<script setup>
const props = defineProps(['item'])
const innerState = ref(Math.random()) // 组件内部状态
</script>

<!-- 列表使用 -->
<div v-for="(item, index) in list" :key="index">
  <MyComponent :item="item" />
  <button @click="deleteItem(index)">删除</button>
</div>

删除索引 1 后,原索引 2 的组件会复用原索引 1 的组件 DOM,内部状态(innerState)不会重置,导致「王五」显示的是「李四」组件的内部状态,完全错位。

场景 3:key 为唯一值(:key="item.id")

表现

删除操作后,DOM 精准更新,无任何错位,输入框 / 组件状态与数据完全匹配。

示例过程

  1. 初始状态:输入框输入「张三」「李四」「王五」;

  2. 删除索引 1(李四,id:2);

  3. 结果:

    • 数据层面:list 变为 [{id:1,name:'张三'}, {id:3,name:'王五'}]
    • 视图层面:直接移除 id:2 对应的 DOM 节点,剩余节点的 DOM 完全保留(输入框内容、组件状态均无错位)。

原因分析

Vue 通过唯一 key(item.id)识别节点:

  • 删除 id:2 的项时,Vue 直接找到 key=2 的 DOM 节点并移除;
  • 剩余项的 key(1、3)与原节点一致,复用 DOM 且状态不变;
  • 无任何 DOM 复用错位,数据与视图完全同步。

本质

唯一 key 让 Vue 能精准匹配「数据项」和「DOM 节点」,diff 算法会:

  1. 对比新旧列表的 key 集合;
  2. 移除不存在的 key(如 2);
  3. 保留存在的 key(1、3),仅更新内容(若有变化);
  4. 新增的 key(若有)则创建新 DOM 节点。

三种场景对比表

场景 删除操作后的表现 底层逻辑 适用场景
无 key 文本看似正常,组件 / 输入框状态可能错位 按位置复用 DOM,无唯一性识别 仅纯文本列表,无状态 / 输入框
key 为 index 输入框 / 组件状态明显错位,数据与视图不匹配 按索引复用 DOM,索引变化导致错位 临时静态列表(无增删改)
key 为唯一值 无错位,DOM 精准更新 按唯一标识匹配节点,精准增删 所有有增删改的列表(推荐)

关键总结

  1. 禁止在有增删改的列表中使用 index 作为 key:这是 Vue 官方明确不推荐的做法,会导致 DOM 复用错位;
  2. 无 key 等同于 key 为 index:Vue 内部会默认使用 index 作为隐式 key,表现一致;
  3. 唯一 key 必须是数据本身的属性:不能是临时生成的唯一值(如 Math.random()),否则每次渲染都会认为是新节点,导致 DOM 全量重建,性能极差;
  4. 唯一 key 的选择:优先使用业务唯一标识(如 id、手机号、订单号),避免使用 index / 随机值。

扩展:Vue3 对 key 的优化

Vue3 的 diff 算法(PatchFlags)相比 Vue2 更高效,但key 的核心作用不变—— 唯一 key 仍是保证列表更新准确性的关键,Vue3 仅优化了「有 key 时的对比效率」,并未改变「无 key/index key 导致的错位问题」。

最终正确示例

<template>
  <div>
    <div v-for="(item, index) in list" :key="item.id">
      <input type="text" v-model="item.name">
      <button @click="deleteItem(index)">删除</button>
    </div>
  </div>
</template>

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

const list = ref([
  { id: 1, name: '张三' },
  { id: 2, name: '李四' },
  { id: 3, name: '王五' }
])

const deleteItem = (index) => {
  list.value.splice(index, 1)
}
</script>

一个让你爽歪歪的请求工具是怎样的

各位好,很久没出来发一发alova文章了,现在的alova已经更新到第3个大版本了,看这篇文章可能会让你对它有个全新的认识。

如果你之前了解过alova,那么大概率知道alova是一个轻量级的请求策略库,通过它的客户端请求hooks和缓存高效地处理前端数据请求。

而到今天,alova3在此基础上实现了更大的提升,它不只是在客户端中使用,现在还能在服务端环境下大放异彩,是一个全栈式的极致高效的请求工具集,其核心目标是帮助开发团队更高效地集成和调用API,并且完美兼容fetch、axios等HTTP客户端,以及react、vue、svelte等UI框架。

具体来说,alova3相比之前的版本,它带来了以下的大更新。

  1. 多级缓存:请求效率和减压利器。
  2. 全栈的请求策略:帮你大大减少请求代码。
  3. 更加现代化的OpenAPI工具:不仅可以自动生成请求代码,还能把api文档搬到编辑器中,交互式地调用api,打破过去的api集成流程。

这里我想郑重声明一下,由于ai大模型对alova知识学习的不足,现在网上充斥着各种ai编写的alova的错误使用的文章,建议大家前往alova官网了解alova的正确使用方法。

一、多级缓存

多级缓存机制可以为你的服务端应用提供最快的请求体验。你可以自由选择单级缓存还是多级缓存使用,它们运行机制如下:

graph TD
    A[用户请求] --> B{检查L1缓存}
    B -->|命中| C[返回数据]
    B -->|未命中| D{检查L2缓存}
    D -->|命中| E[更新L1缓存]
    D -->|未命中| F[请求API接口]
    F --> G[更新L2缓存]
    E --> C
    G --> E
    C --> H[结束]

默认情况下,alova 的一级缓存是简单的 object 以 key-value 的方式缓存,而二级缓存在不同环境下的默认表现也不同,具体看下面。

客户端

在客户端环境下,一级缓存一般用于短时的请求数据缓存,例如几分钟或几秒钟的频繁请求相同数据带来的性能消耗,当你在写 todo 详情页的时候,你可能会想到用户会频繁在 todo 列表中点击查看详情,此时使用一级缓存可以大大提升效率,降低服务端压力。

// 对详情数据设置60秒的一级缓存
alovaInstance.Get('/todo/detail', {
  params: {
    id: 'xxx'
  },
  cacheFor: 60 * 1000, // 设置60秒的一级缓存
  // ...
});

而二级缓存可用于一些枚举数据、节日等长期不变的请求为主,默认使用localStorage存储。

// 对枚举数据设置3天缓存时间
alovaInstance.Get('/config/enum', {
  params: {
    key: 'xxx'
  },
  cacheFor: {
    mode: 'restore',
    expire: 3 * 24 * 3600 * 1000 // 缓存3天时间
  }, 
});

服务端

在服务端环境下,多级缓存机制特别适用于以下场景:

  1. 高访问频率和低延迟需求:例如热门新闻、商品详情,可以进一步减少网络开销,在网络不稳定时也保持更快的响应。

  2. 减轻下游服务器压力:例如有访问高峰期的服务,上层缓存可以有效减少对后端数据库和微服务的压力。

  3. 整合多个下游服务器的数据合并和处理:多个串行请求可能导致更长的响应时间,也可能因复杂的数据转换消耗性能,可将转换后的数据进行缓存。

  4. API 速率限制和计费:天气预报服务 API 每小时更新一次天气信息,地理位置数据 API 等。

  5. 鉴权数据:用户token等数据缓存到内存中,避免频繁的鉴权操作。

  6. 请求速率限制等分布式数据:用户的请求次数限制等数据。

具体可以看这篇文章

以下是一个使用进程间内存共享适配器加 LRU cache 作为一级缓存,redis 作为二级缓存的示例:

const { createPSCAdapter, NodeSyncAdapter } = require('@alova/psc');
const { LRUCache } = require('lru-cache');
const RedisStorageAdapter = require('./adapter-redis');

function lRUCache(options = {}) {
  const cache = new LRUCache(options);
  return {
    set(key, value) {
      return cache.set(key, value);
    },

    get(key) {
      return cache.get(key);
    },

    remove(key) {
      return cache.delete(key);
    },

    clear() {
      return cache.clear();
    }
  };
}

const alovaInstance = createAlova({
  // ...

  // 进程间共享缓存适配器
  l1Cache: createPSCAdapter(
    NodeSyncAdapter(),
    lRUCache({
      max: 1000,
      ttl: 1000 * 60 * 10
    })
  ),

  // redis缓存适配器
  l2Cache: new RedisStorageAdapter({
    host: 'localhost',
    port: 6379,
    username: 'default',
    password: 'my-top-secret',
    db: 0
  })
});

通过多级缓存机制,alova3能够实现:

  • 响应时间减少80%+:通过内存缓存实现毫秒级响应
  • 服务器负载降低60%+:减少不必要的API调用
  • 离线体验优化:持久化缓存支持完整的离线功能
  • 带宽节约:减少重复数据传输

这种多级缓存架构使得alova3特别适合需要高性能数据访问的现代Web应用,在微服务架构和分布式系统中也表现出色。

二、全栈的请求策略

服务端请求策略

alova3一个关键特性是引入了对服务端的完整支持,你不仅可以在nodejs、deno、bun等运行时中使用,还提供了独有的服务端请求策略,简称server hooks,它可以很方便地处理分布式的BFF、API网关、第三方token管理等,同时还有请求速率限制、请求重试、验证码发送与校验等模块,可以快速实现分布式的特定业务逻辑。

BFF、API网关、第三方token管理等,在这篇文章中有详细的介绍,这边我们来看看验证码发送校验模块的使用。

按照惯例,首先创建一个alova实例。

const { createAlova } = require('alova/server');
const RedisStorageAdapter = require('@alova/storage-redis');
const adapterFetch = require('alova/fetch');

export const alovaInstance = createAlova({
  // ...
  requestAdapter: adapterFetch(),
  // redis缓存适配器
  l2Cache: new RedisStorageAdapter({
    host: 'localhost',
    port: 6379,
    username: 'default',
    password: 'my-top-secret',
    db: 0
  })
});

然后再创建一个验证码提供者函数,并指定使用redis适配器作为缓存载体,以便实现分布式环境下的使用。

const { createCaptchaProvider } = require('alova/server');

const { sendCaptcha, verifyCaptcha } = createCaptchaProvider({
  store: alovaInstance.options.l2Cache
});

export sendCaptcha;
export verifyCaptcha;

第三步,再发送验证码,这里我们使用retry来实现重试,提高成功率。

// 创建一个发送验证码的method实例
const createCaptchaMethod = (code, key) = > alovaInstance.Post('/api/captcha', {
  code,
  email: key,
});

// 使用sendCaptcha hook包装createCaptchaMethod
const captchaMethod = sendCaptcha(createCaptchaMethod, {
  key: 'xxx@xxx.com'
});

// 使用retry hook包装captchaMethod,并通过await发送请求并获取响应结果
const result = await retry(captchaMethod, {
  retry: 3,
  backoff: {
    delay: 2000
  }
});

最后一步,通过用户的提交来验证验证码。

const fakeCaptchaFromUserInput = '1234';
const isValid = await verifyCaptcha(fakeCaptchaFromUserInput, key);
console.log(isValid ? '验证通过' : '验证码错误');

客户端的请求策略

alova3的客户端请求策略与之前的版本大相径庭,依然提供了useRequestuseWatcherusePagination等UI框架相关的hooks,在这里大家应该都很熟悉了,但不同的是,无论你使用react、vue还是svelte,这些hook都由统一的alova/client中导入。

import { useRequest } from 'alova/client';

const { data, loading, error, send } = useRequest(alovaInstance.Get('/todo/list'));

全栈框架nuxt支持

虽然在之前的版本中,alova已经支持在next、nuxt、svelitekit等SSR框架中使用,不过在alova3中,提供了一个专门适配nuxt的statesHook,可以在使用useRequestuseWatcher等几乎所有的hooks时不仅可以同步两端的数据,像内置的useFetch一样避免在客户端重复请求,还可以同步例如DateError等以及自定义类型的数据,用法也非常简单,设置一下NuxtHook即可。

import { createAlova } from 'alova';
import NuxtHook from 'alova/nuxt';

export const alovaInstance = createAlova({
  // ...
  statesHook: NuxtHook({
    nuxtApp: useNuxtApp // 必须指定useNuxtApp
  })
});

三、工具链支持:更加现代化的OpenAPI工具

除了核心库的增强,alova3还提供了开发工具和vscode扩展来优化工作流。

如果你的项目支持openapi,这些开发工具可以基本消除API文档,让你直接在编辑器中查找接口、查看完整文档并交互式地快速插入接口的调用代码,很大程度地提升了开发效率和体验。

同时,你还可以完全控制openapi在前端生成的内容,例如过滤掉某些接口,新增、删除或修改某个接口的参数等,这些在openapi文件不够规范,或者有错误时非常有用,并且是其他openapi自动生成工具所不具备的。

一起来具体看看怎么回事。

快速查找和插入调用代码

自定义修改生成内容

在一般情况下,服务端的返回数据格式会外包一层,例如:

{
  code: 0,
  message: "success",
  data: {
    // ...
  }
}

而此时我们希望生成的响应数据类型为response.data,你可以在alova配置文件中使用payloadModifier插件。

import { defineConfig } from '@alova/wormhole';
import { payloadModifier } from '@alova/wormhole/plugin';

export default defineConfig({
  generator: [
    {
      // ...
      plugin: [
        payloadModifier([
          {
            scope: 'response',
            handler: schema => schema.data
          }
        ])
      ]
    }
  ]
});

你也还可以通过handleApi实现完全的自定义,例如以下是一个修改tag的例子:

export default defineConfig({
  generator: [
    {
      handleApi: apiDescription => {
        if (apiDescription.url.includes('/user')) {
          apiDescription.tags = ['userTag'];
        }
        return apiDescription;
      }
    }
  ]
});

边查边接入接口

使用alova的vscode扩展,你可以直接在编辑器中查找接口,并直接插入调用代码,不仅如此,接口的所有相关信息都可以直接在代码中查看,像这样。

image.png

弹框中能清楚的看到接口的描述信息、请求参数、响应参数等,你可以一边看着弹框中的参数,一边在代码中传参,这个点子太棒啦,再也不用在api文档和编辑器中来回切换,然后每次还要重新找对应信息了。

编辑器中的api文档

这个vscode扩展还提供了在编辑器中查看接口文档的功能,你可以直接在编辑的侧边栏中查看接口文档,就像这样。

也可以在代码中的调用代码上直接点击“view documentation”打开对应的接口文档,可以实现快速查看接口的描述信息、请求参数、响应参数等。

写在最后

到这里,你应该感受到了一点alova3的不同了吧,通过以上的方式让前端在接口集成方向很大程度地提升效率,无论你是前端开发者希望提升应用性能,还是后端工程师需要构建高效的API网关,亦或是全栈开发者追求统一的开发体验,alova3都能为你提供完整的支持。

如果觉得alova还不错,真诚希望你可以尝试体验一下,也可以给我们来一个免费的github stars

访问alovajs的官网查看更多详细信息:alovajs官网

有兴趣可以加入我们的交流社区,在第一时间获取到最新进展,也能直接和开发团队交流,提出你的想法和建议。

有任何问题,你可以加入以下群聊咨询,也可以在github 仓库中发布 Discussions,如果遇到问题,也请在github 的 issues中提交,我们会在最快的时间解决。

如何从Axios平滑迁移到Alova

Alova 是谁?

Alova 是一个请求工具集,它可以配合axios、fetch等请求工具实现高效的接口集成,是用来优化接口集成体验的。此外,它还能提供一些 Axios 不具备的便利功能,比如:

  • 内置缓存机制:不用自己操心数据缓存,Alova 能帮你自动管理,提升性能。
  • 请求策略灵活:可以根据不同场景设置不同的请求逻辑,比如强制刷新、依赖请求等。
  • 多框架支持:无论是 Vue、React 还是 Svelte、Solid,Alova 都能很好地集成。
  • 状态管理集成:请求的数据可以直接与组件状态关联,减少冗余代码。
  • 强大的适配器系统:支持自定义适配器,比如我们这次要讲的 —— 用 Alova 来兼容你现有的 Axios 实例!

简单来说,Alova 在保留类似 Axios 易用性的基础上,还提供了更多开箱即用的高级功能请移步到alova官方文档查看。

为什么要从 Axios 迁移到 Alova?

你可能会问:“Axios 用得好好的,为什么要迁?” 其实,迁移不一定是“非换不可”,而是一种“锦上添花”的选择。以下是一些常见的迁移动机:

  1. 希望简化复杂请求逻辑的管理:比如依赖请求、串并行控制、自动重试等,Alova 提供了更优雅的解决方案。
  2. 想要内置缓存,减少重复请求:如果你的应用有很多重复请求,Alova 的缓存机制可以显著提升性能。
  3. 多框架适配更方便:如果你在多个项目中使用不同框架,Alova 能提供一致的体验。
  4. 想逐步优化现有项目:不想大动干戈重写代码,但又想引入更现代的请求管理方式。

如果你有以上需求,那 Alova 就非常值得尝试!

从 Axios 迁移到 Alova,到底难不难?

Alova 官方提供了非常友好的 Axios 迁移方案,核心就一个字:稳。

官方迁移指南的设计理念是:

  • 最小改动:你不需要一下子重写所有代码,只需引入 Alova,然后逐步迁移。
  • 渐进迁移:一个接口一个接口地改,节奏由你掌控。
  • 保持一致性:你现有的 Axios 实例、配置、拦截器,统统都能继续用!
  • 新老共存:迁移期间,Axios 和 Alova 可以同时运行,互不干扰。

是不是听起来就很贴心?接下来,我们就来看看具体怎么操作。

从 Axios 迁移到 Alova 的具体步骤

第一步:安装 Alova 和 Axios 适配器

首先,你需要通过包管理工具安装 Alova 以及它的 Axios 适配器。

# 如果你用 npm
npm install alova @alova/adapter-axios

# 或者用 yarn
yarn add alova @alova/adapter-axios

# 或者用 pnpm
pnpm install alova @alova/adapter-axios

第二步:创建 Alova 实例,并传入你的 Axios 实例

这一步是关键,你可以直接复用你现有的 Axios 实例!

import { createAlova } from 'alova';
import { axiosRequestAdapter } from '@alova/adapter-axios';
import axiosInstance from './your-axios-instance'; // 你原来就有的 axios 实例

const alovaInst = createAlova({
  statesHook, // 这里填你项目里用的 Hook,比如 VueHook / ReactHook / SvelteHook
  requestAdapter: axiosRequestAdapter({
    axios: axiosInstance // 把你原来的 axios 实例传进去
  })
});

你啥都不用改,原来的 axios 实例、拦截器、配置全都有效!

第三步:继续使用原有 Axios 代码,不用急着改

没错,哪怕你刚刚创建了 Alova 实例,你原来的 Axios 代码照样跑得好好的!你可以慢慢来,不用急着一口气全改完。

比如这样:

const getUser = id => axios.get(`/user/${id}`); // 你原来的写法,依旧能用

当然,你也可以开始尝鲜,用 Alova 的方式发起请求:

const getUser = id => alovaInst.Get(`/user/${id}`);

// 在组件里使用(以 React 为例)
const { loading, data, error } = useRequest(getUser(userId));

第四步:逐步把 Axios 请求改写为 Alova 请求

等你熟悉了 Alova,就可以开始把原来的 axios.getaxios.post 等方法,逐步替换为 Alova 的 alovaInst.GetalovaInst.Post,非常简单:

原来的写法:

const todoList = id => axios.get('/todo');

改写为 Alova 写法:

const todoList = id => alovaInst.Get('/todo');

带参数的 POST 请求:

// 原来的写法
const submitTodo = data =>
  axios.post('/todo', data, {
    responseType: 'json',
    responseEncoding: 'utf8'
  });

// Alova 写法
const submitTodo = data =>
  alovaInst.Post('/todo', data, {
    responseType: 'json',
    responseEncoding: 'utf8'
  });

看,参数基本都能直接对应过去,写法也类似,学习成本极低!

最后

从 Axios 迁移到 Alova,其实并没有想象中那么难,甚至可以说非常平滑,尤其是对老项目的兼容性做得非常友好。

所以,如果你:

  • 已经在使用 Axios,但想尝试更现代的请求管理方式;
  • 希望引入缓存、优化请求策略、提升应用性能;
  • 想逐步优化代码,又不想一夜之间重写所有逻辑;

不妨试试 Alova,按照这个迁移指南可以轻松上手。

如果觉得alova还不错,真诚希望你可以尝试体验一下,也可以给我们来一个免费的github stars

访问alovajs的官网查看更多详细信息:alovajs官网

有兴趣可以加入我们的交流社区,在第一时间获取到最新进展,也能直接和开发团队交流,提出你的想法和建议。

Vue3和Vue2的核心区别?很多开发者都没完全搞懂的10个细节

大家好,我是大华!这篇我们来讲解Vue2和Vue3的核心区别在哪里?

Vue3是Vue2的升级版,不仅更快,还更好用。它解决了Vue2中一些让人头疼的问题,比如动态添加属性不响应、组件必须包在一个根元素里等等。

下面通过10个常见的对比例子,让你快速看懂Vue3到底新在哪儿、好在哪儿。

1. 响应式系统:Object.defineProperty vs Proxy

Vue 2 无法监听动态添加的属性(除非用 Vue.set);Vue 3 可以直接响应。

// Vue 2 不会触发更新
this.obj.newProp = 'hello'

// Vue 2 正确方式
this.$set(this.obj, 'newProp', 'hello')

// Vue 3 直接赋值即可响应
this.obj.newProp = 'hello'

2. Composition API(组合式 API)



export default {
  data() {
    return { count: 0 }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}




import { ref } from 'vue'

const count = ref(0)
const increment = () => count.value++


3. TypeScript 支持

// Vue 3 + TypeScript(能更好的支持)

interface Props {
  msg: string
}
const props = defineProps()

Vue 2 虽可通过 vue-class-componentvue-property-decorator 支持 TS,但配置复杂且类型推导弱。


4. Fragment(多根节点)



  <header>Header</header>
  <main>Main</main>




  <header>Header</header>
  <main>Main</main>


5. Teleport(传送门)

将 modal 渲染到 body 下,避免样式嵌套问题


  Open Modal
  
    <div class="modal">
      <p>Hello from Teleport!</p>
      Close
    </div>
  



import { ref } from 'vue'
const open = ref(false)

Vue 2 需手动操作 DOM 或使用第三方库(如 portal-vue)。


6. Suspense(异步组件加载)




const res = await fetch('/api/data')
const data = await res.json()



  <div>{{ data }}</div>



  
    
      
    
    
      <div>Loading...</div>
    
  

Vue 2 无原生 ``,需自行管理 loading 状态。


7. 全局 API 变更

// Vue 2
Vue.component('MyButton', MyButton)
Vue.directive('focus', focusDirective)

// Vue 3
import { createApp } from 'vue'
const app = createApp(App)
app.component('MyButton', MyButton)
app.directive('focus', focusDirective)
app.mount('#app')

Vue 3 的应用实例彼此隔离,适合微前端或多实例场景。


8. 生命周期钩子命名变化

// Vue 2
export default {
  beforeDestroy() { /* cleanup */ },
  destroyed() { /* final */ }
}

// Vue 3(Options API 写法)
export default {
  beforeUnmount() { /* cleanup */ },
  unmounted() { /* final */ }
}

// Vue 3(Composition API)
import { onBeforeUnmount, onUnmounted } from 'vue'
onBeforeUnmount(() => { /* cleanup */ })
onUnmounted(() => { /* final */ })

9. v-model 多绑定












10. 显式声明 emits(推荐)



const emit = defineEmits(['submit', 'cancel'])

const handleSubmit = () => emit('submit')




const emit = defineEmits({
  submit: (payload) => typeof payload === 'string',
  cancel: null
})

Vue 2 中 $emit 无需声明,但不利于工具链和文档生成。


这些示例覆盖了 Vue2 和 Vue3 比较关键的差异点。通过代码对比,可以更清楚地看到 Vue3 在开发体验、性能、灵活性和工程化方面有明细的提升。

结尾

总的来说,Vue3 在保持简单上手的同时,增加了更多实用又强大的功能。不管是写代码更轻松了,还是对 TypeScript、大型项目的支持更好了,都让开发者的工作变得更高效。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《async/await 到底要不要加 try-catch?异步错误处理最佳实践》

《如何查看 SpringBoot 当前线程数?3 种方法亲测有效》

《Java 开发必看:什么时候用 for,什么时候用 Stream?》

《这 10 个 MySQL 高级用法,让你的代码又快又好看》

揭秘!TinyEngine低代码源码如何玩转双向转换?

本文由TinyEngine低代码源码转换功能贡献者张珈瑜原创。

背景

当前主流低代码平台普遍采用“单向出码”模式,仅支持将 DSL(Domain Specific Language,领域特定语言)转换为 Vue 或 React 源代码。一旦开发者在生成代码后手动修改了源码,平台通常无法将这些修改同步回可视化编辑器,导致代码与可视化配置割裂,严重影响开发效率与协同维护。本项目旨在构建低代码 Vue/React 源代码到 DSL 的反向转换机制,打通可视化搭建与源码开发之间的断层,实现从 UI 配置到源码编写的无缝协同。

Vue-To-DSL 方案

目标

将 Vue 单文件组件(SFC)、整包工程或 ZIP 压缩包逆向转换为 TinyEngine 所需的 DSL Schema。

核心依赖

  • @vue/compiler-sfc / @vue/compiler-dom:解析 SFC 与模板 AST
  • @babel/parser / traverse / types:脚本 AST(支持 TS/JSX)
  • jszip:ZIP 文件读取(Node 与浏览器双端支持)
  • vue:仅用于类型对齐

数据流

0.PNG

解析流程详解

1. SFC 粗分

  • 使用 @vue/compiler-sfc.parse 获取 descriptor
  • 提取 template / script / scriptSetup / styles / customBlocks
  • 保留语言类型(如 lang=&#34;ts&#34;)和 scoped 状态

2. 模板解析(Template)

  • 使用 @vue/compiler-dom.parse 构建 AST
  • 递归生成组件树节点 { componentName, props, children }
  • 指令处理
    • v-ifcondition: JSExpression
    • v-forloop: { type: 'JSExpression', value: '...' }
    • v-model / v-show / v-on / v-bind → 映射至 props 或事件
    • v-slotslot: name
  • 文本节点
    • 纯文本 → Text 组件
    • 插值表达式 → Text + JSExpression
  • 组件名归一化
    • 优先使用 componentMap
    • HTML 原生标签保留小写
    • tiny-icon-* → 统一为 Iconname 属性设为 PascalCase 名称

3. 脚本解析(Script)

  • 使用 Babel 解析 TS/JSX
  • 组合式 API(script setup)
    • reactive() / ref()state
    • computed()computed
    • 顶层函数 → methods
    • onMounted 等 → lifecycle
  • Options API
    • 识别 data / methods / computed / props / 生命周期钩子
  • 源码恢复
    • 利用 AST 节点位置切片还原函数体
    • 箭头函数转为命名函数字符串
  • 错误处理:非 strict 模式下收集错误,不中断流程

4. 样式解析(Style)

  • 合并所有 `` 块内容
  • 记录 scopedlang
  • 提供辅助工具(不直接写入 Schema):
    • parseCSSRules:抽取 CSS 规则
    • extractCSSVariables:提取 CSS 变量
    • extractMediaQueries:媒体查询识别

5. Schema 生成与归一化

  • Page Schema
    • 根节点为 Page,自动填充 fileNamemetaid
    • 行为域统一包装为 JSFunction / JSExpression
    • 深度清理多余空白字符
  • App Schema(多页面聚合):
    • 页面:src/views/**/*.vue
    • 国际化:src/i18n/{en_US,zh_CN}.json
    • 工具函数:src/utils.js(正则解析导出项)
    • 数据源:src/lowcodeConfig/dataSource.json
    • 全局状态:src/stores/*.js(轻量识别 Pinia defineStore
    • 路由元信息:从 src/router/index.js 提取 name / path / isHome

6. 转换器接口

  • convertFromString(code, fileName?)
  • convertFromFile(filePath)
  • convertMultipleFiles(files)
  • convertAppDirectory(appDir)
  • convertAppFromZip(zipBuffer)

React-To-DSL 方案

目标

将单个 React 组件(JSX/TSX)逆向转换为 TinyEngine 可消费的 DSL(IAppSchema),当前聚焦 单文件 → 单页面/区块 场景。

核心依赖

  • @babel/parser / traverse / generator:AST 解析与代码生成
  • nanoid:生成唯一 ID

转换流程

1.PNG

关键步骤说明

1. AST 解析

  • 启用 jsx + typescript 插件
  • 定位首个返回 JSX 的函数/类组件
  • 记录 useState 初始值节点、组件定义路径

2. JSX → children 树构建

  • 组件名
    • JSXIdentifier → 直接使用
    • JSXMemberExpression → 拼接如 Form.Item
    • 兜底为 Fragment
  • Props 处理
    • 字面量 → 直接值
    • 表达式 → JSExpression
    • Spread 属性 → 特殊 key '...'
  • Children
    • 文本 → 包装为 span + props.children
    • 表达式容器:
      • 若为 arr.map(item => ) → 提取 arr 作为 loop
      • 否则 → Fragment + JSExpression

3. 表达式序列化

  • 字面量(string/number/bool/null)→ 原值
  • 对象/数组 → 递归处理,Spread 元素标记为 '...'
  • 其他表达式(函数调用、三元等)→ 源码字符串 + JSExpression

4. State 与方法提取

  • State:仅首个 useState 的初始值
  • Methods
    • 函数组件:顶层函数声明或变量赋值函数
    • 类组件:非 renderClassMethod 或箭头属性
  • 生命周期:类组件中的 componentDidMount 等白名单方法

5. 组件归一化

  • 应用 defaultComponentMap(如 FormTinyForm
  • DatabaseOutlinedIcon + props.name = 'IconPanelMini'
  • style 对象 → 转为 kebab-case: value; 字符串
  • valuemodelValue(适配 Tiny 组件)

6. Schema 装配

  • PageSchema
    • componentName: 'Page''Block'
    • meta: 默认 isHome=true, router='/'
    • children: 来自 JSX 树
    • state/methods/lifeCycles: 提取结果
  • AppSchema:包裹单个 Page,其余字段初始化为空

(本项目为开源之夏活动贡献,欢迎大家体验并使用)源码可参考:

快速上手

前置条件: 已安装 Node.js (>=18)pnpm (>=8)git,本地 8090 端口可用。

启动项目

git clone -b ospp-2025/source-to-dsl https://github.com/opentiny/tiny-engine.git
cd tiny-engine
pnpm install
pnpm dev

启动成功后,访问 http://localhost:8090/?type=app&id=1&tenant=1

可视化导入 Vue 文件

进入上方地址后,点击页面右上角的“导入”。支持三种来源:

  • 单个 .vue 文件:适合导入单页或区块。
  • 项目目录:自动识别 src/views 下的页面文件。
  • ZIP 压缩包:打包后的 Vue 项目一键导入。

导入流程(任选其一):

  1. 单个 .vue 文件
  • 选择“单页上传”,挑选本地 .vue 文件。
  • 若存在同名页面,按提示“覆盖/跳过/全部覆盖”进行处理。
  • 导入完成后,在“静态页面”列表中可见,双击打开编辑。
  1. 项目目录
  • 选择“目录上传”,指向本地项目根目录。
  • 系统自动扫描 src/views 并导入页面;遇到重名同样可选择覆盖策略。
  • 完成后,页面会按目录结构展示在左侧列表。
  1. ZIP 压缩包
  • 选择“项目压缩包”,上传打包好的 Vue 项目 zip。
  • 支持批量导入与重名处理,完成后即可在列表中浏览与打开。

选择单页vue文件上传方式进行导入:

由于已经存在CreateVm页面,弹出了提示框,需要选择是否覆盖,这里点击确定:

在静态页面列表可查看到导入的页面,双击即可点开:

选择项目目录或项目压缩包上传方式进行导入:

选择目录导入则选择本地的目录进行上传:

选择项目压缩包则选择vue项目zip进行上传:

导入时有重名文件则会弹出提示框,选择是否覆盖,这里选择全选+确定:

可以看到整个项目已经被导入到可视化编辑器了

React-To-DSL 测试用例

当前 React-To-DSL 以测试用例形态展示能力,可在包内直接运行:

cd packages/react-to-dsl
pnpm test

输入样例:查看用例中的 React 组件源码(JSX)

输出结果:测试通过时会生成/断言对应的 DSL 结构,便于对照验证

(本项目为开源之夏活动贡献,欢迎大家体验并使用)源码可参考:

结语

本项目成功实现了 Vue/React 源码 DSL 的双向转换机制,有效解决了低代码平台“单向出码”导致的协同断层问题。通过模块化解析、健壮的错误处理与灵活的组件映射策略,确保了转换的准确性与实用性。

感谢导师的悉心指导,以及 OpenTiny 社区与开源之夏活动组委会的支持,让我有机会参与这一具有实际价值的开源项目!

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

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

从生活实例解释什么是AST抽象语法树

AST(Abstract Syntax Tree,抽象语法树)  听起来很高深,但其实它的核心概念非常简单:把“文本”变成“结构化的数据对象” ,方便机器理解和操作。就是把字符串形式的代码转换成机器能看懂、能操作的结构化数据—— 你可以把它理解成:代码的 “说明书”/“骨架”

机器(比如 Babel、Vue 编译器)看不懂直接的字符串代码(比如const a = 1),但能看懂 AST 这种 “键值对 + 层级结构” 的 JSON-like 数据,从而实现「修改代码、转换代码、分析代码」。

为了让你彻底明白,我们分两步走:先看生活中的例子,再看 Vue 中的实际应用。


第一部分:生活中的例子 —— “点外卖”

假设你是个复杂的客户,你给服务员说了一句很长的话(这就是源代码 Source Code):

“我要一个牛肉汉堡,不要洋葱,加双份芝士,还要一杯可乐,去冰。”

1. 为什么需要 AST?

如果你直接把这句话扔给后厨的厨师,厨师可能听懵了,或者容易漏掉细节。计算机也是一样,它看不懂这一长串字符串,它需要一个清晰的清单

2. 生成 AST(解析过程)

前台服务员(编译器/解析器)听到这句话后,会在点餐系统里输入一张结构化的单子。这张单子就是 AST

它大概长这样:

{
  &#34;类型&#34;: &#34;订单&#34;,
  &#34;内容&#34;: [
    {
      &#34;商品&#34;: &#34;牛肉汉堡&#34;,
      &#34;配料修改&#34;: [
        { &#34;操作&#34;: &#34;移除&#34;, &#34;物品&#34;: &#34;洋葱&#34; },
        { &#34;操作&#34;: &#34;添加&#34;, &#34;物品&#34;: &#34;芝士&#34;, &#34;数量&#34;: 2 }
      ]
    },
    {
      &#34;商品&#34;: &#34;可乐&#34;,
      &#34;属性&#34;: [
        { &#34;温度&#34;: &#34;去冰&#34; }
      ]
    }
  ]
}

3. 这个例子的核心点:

  • 源代码:那句口语(字符串)。
  • AST:那张结构化的单子(JSON 对象)。
  • 作用:有了这张单子,厨师(浏览器/JS引擎)不需要去分析语法,直接看字段就能精准干活;甚至如果需要把“汉堡”换成“三明治”,改单子(修改 AST)比改口语容易得多。

二、回到代码:AST 到底解决了什么问题?

场景:你写了一行代码 const msg = 'hello',想把它改成 var message = 'hello'

  • 如果你直接改字符串:需要 “找 const→替换成 var,找 msg→替换成 message”,但代码复杂时(比如嵌套函数、多文件),手动 / 字符串替换极易出错;
  • 用 AST 改:机器先把代码转成 AST(结构化数据),再精准修改节点,最后转回代码 —— 安全、精准、可批量操作。

第一步:解析(Parse)—— 代码→AST

const msg = 'hello' 对应的 AST 简化结构:

{
  &#34;type&#34;: &#34;VariableDeclaration&#34;, // 节点类型:变量声明
  &#34;kind&#34;: &#34;const&#34;, // 变量类型:const
  &#34;declarations&#34;: [
    {
      &#34;type&#34;: &#34;VariableDeclarator&#34;,
      &#34;id&#34;: { &#34;type&#34;: &#34;Identifier&#34;, &#34;name&#34;: &#34;msg&#34; }, // 变量名:msg
      &#34;init&#34;: { &#34;type&#34;: &#34;Literal&#34;, &#34;value&#34;: &#34;hello&#34; } // 变量值:hello
    }
  ]
}

此时代码不再是字符串,而是 “变量声明节点 + 变量名节点 + 值节点” 的结构化数据,每个部分都有明确标识。

第二步:转换(Transform)—— 修改 AST

机器遍历 AST,精准修改指定节点。比如我们想把const改成varmsg改成message

// 伪代码:修改 AST 节点 
ast.kind = &#34;var&#34;; // 把const换成var
ast.declarations[0].id.name = &#34;message&#34;; // 把msg换成message

修改后的 AST

{
  &#34;type&#34;: &#34;VariableDeclaration&#34;,
  &#34;kind&#34;: &#34;var&#34;, // 已修改
  &#34;declarations&#34;: [
    {
      &#34;type&#34;: &#34;VariableDeclarator&#34;,
      &#34;id&#34;: { &#34;type&#34;: &#34;Identifier&#34;, &#34;name&#34;: &#34;message&#34; }, // 已修改
      &#34;init&#34;: { &#34;type&#34;: &#34;Literal&#34;, &#34;value&#34;: &#34;hello&#34; }
    }
  ]
}

第三步:生成(Generate)——AST→代码

把修改后的 AST 转回字符串代码,核心是 “遍历 AST 树,根据节点类型拼接代码”。我们可以写一个极简的生成函数模拟这个过程:

// 迷你AST生成器:遍历节点拼接代码
function generateCode(astNode) {
  // 处理变量声明节点
  if (astNode.type === &#34;VariableDeclaration&#34;) {
    const declarations = astNode.declarations.map(decl => {
      const name = decl.id.name;
      const value = decl.init.value;
      return `${name} = '${value}'`;
    }).join(', ');
    return `${astNode.kind} ${declarations};`;
  }
}

// 执行生成
const newCode = generateCode(ast);
console.log(newCode); // 输出:var message = 'hello';

修改后的 AST 转回字符串代码:var message = 'hello'

真实场景中,Babel、Vue 编译器会用更完善的生成器(如@babel/generator),但核心逻辑都是 “节点类型→代码片段→拼接”。

Vue 中的 AST

在 Vue 中,AST 主要用于模板编译(Template Compilation)

浏览器其实只认识 HTML、CSS 和 JS。它根本不认识 Vue 的 .vue 文件,也不认识 v-if、v-for这种语法。

Vue 需要把你的 `` 变成浏览器能运行的 render 函数,中间的桥梁就是 AST

  1. 源代码(你写的 Vue 模板)
<div id="app">
  <p>你好</p>
</div>

这就好比刚才那句“我要一个汉堡...”,对浏览器来说,这只是一串普通的字符串。

2. 解析成的 AST(Vue 内部生成的树)

Vue 的编译器会把上面的 HTML 字符串“拆解”,变成下面这样的 JavaScript 对象(简化版):

const ast = {
  // 标签类型
  tag: &#34;div&#34;,
  // 属性列表
  attrs: [{ name: &#34;id&#34;, value: &#34;app&#34; }],
  // 子节点列表
  children: [
    {
      tag: &#34;p&#34;,
      // 指令被解析成了专门的属性
      if: &#34;show&#34;, 
      children: [
        {
          type: &#34;text&#34;,
          text: &#34;你好&#34;
        }
      ]
    }
  ]
};

3. 为什么要转成 AST?(Vue 拿它干什么?)

一旦变成了上面这种树形对象,Vue 就可以对代码进行**“手术”“优化”**:

  1. 识别指令:Vue 扫描这棵树,发现 p 节点有个 if: "show"。于是它知道:生成代码时,要给这行代码加个 if (show) { ... } 的判断逻辑。
  2. 静态提升(优化性能) :Vue 3 扫描 AST,发现 "你好" 是纯文本,永远不会变。Vue 就会给它打个标记:“这块不需要每次渲染都比较,直接复用”。(如果只是看字符串,很难做这种复杂的分析)。

AST 的下一步,是生成 render 函数代码(渲染函数)。

要搞懂 AST 如何转回字符串代码,核心是理解「AST 生成器(Generator)」的工作逻辑 —— 它本质是深度遍历 AST 树,根据每个节点的类型和属性,拼接出对应的代码字符串

第一阶段:AST ➡️ Render 函数代码

这就是浏览器能“认识”的第一步:因为它变成了标准的 JavaScript 代码。

浏览器虽然不懂 <p>,但它懂 JavaScript 的 if 或者三元运算符 ? :。

举个栗子

你的 Vue 模板(源代码):

<div id="app">
  <p>你好</p>
</div>

生成的 AST(中间产物,略):
(就是一个描述结构的 JSON 对象)

AST 转换后生成的 Render 函数代码(最终产物):

Vue 的编译器会根据 AST,拼接出一段 纯 JavaScript 字符串,长得像这样(为了方便阅读,我简化了 Vue 内部的函数名):

function render() {
  // _c = createElement (创建元素)
  // _v = createTextVNode (创建文本)
  // _e = createEmptyVNode (创建空节点,用于 v-if 为 false 时)

  return _c('div', { attrs: { &#34;id&#34;: &#34;app&#34; } }, [
    // 重点看这里!v-if 被变成了 JS 的三元运算符
    (show) 
      ? _c('p', [_v(&#34;你好&#34;)]) 
      : _e()
  ])
}

这里的核心变化:

  1. HTML 标签 变成了函数调用 _c('div')。
  2. v-if="show"  消失了,变成了原生的 JS 逻辑 (show) ? ... : ...。
  3. 浏览器完全认识这段代码!  这就是一段标准的 JS 函数,里面全是函数调用和逻辑判断。

第二阶段:浏览器怎么把这段代码变成画面?

你可能会问:“浏览器运行了这个函数,然后呢?屏幕上怎么就有字了?”

这里有两个步骤:生成虚拟 DOM ➡️ 转为真实 DOM

1. 运行 Render 函数,得到 虚拟 DOM (Virtual DOM)

当 Vue 运行时(Runtime)执行上面的 render 函数时,浏览器并不会立即去画界面,而是返回一个 JS 对象树,这叫做 VNode(虚拟节点)

执行 render() 后得到的返回值:

// 这是一个纯 JS 对象,不是真实的 DOM 元素
{
  tag: 'div',
  data: { attrs: { id: 'app' } },
  children: [
    {
      tag: 'p',
      children: [{ text: '你好' }]
    }
  ]
}

为什么要多这一步?
因为操作真实 DOM(网页上的元素)非常慢,而操作 JS 对象非常快。Vue 可以在这个 JS 对象上做各种计算(比如 Diff 算法),确认没问题了,再动手改网页。

2. Patch(修补/渲染)➡️ 真实 DOM

这是最后一步。Vue 的运行时系统(Runtime)会拿着上面的 VNode,调用浏览器底层的 DOM API

这时候,浏览器才真正干活:

  • Vue 看到 tag: 'div' ➡️ 调用 document.createElement('div')
  • Vue 看到 attrs: { id: 'app' } ➡️ 调用 el.setAttribute('id', 'app')
  • Vue 看到 text: '你好' ➡️ 调用 document.createTextNode('你好')
  • 最后把它们拼在一起,挂载到页面上。

总结

  • AST 是什么?
    它是代码的骨架图。它把代码从“一行行文本”变成了“层级分明的对象”。
  • Vue 里的流程:
    template (字符串) ➡️ AST (树形对象)  ➡️ render 函数 (可执行 JS) ➡️ 虚拟 DOM ➡️ 真实 DOM。

vite创建的vue项目是通过babel还是vue自己编译器编译的

在默认的 Vite + Vue 项目中,绝大多数情况下,是不需要 Babel 的,也没有用 Babel。 它的分工是这样的:

  1. .vue 文件的编译(Template -> Render函数) :完全依靠 Vue 自己的编译器(@vue/compiler-sfc)。
  2. JS/TS 语法的转译(ES6+ -> 浏览器能跑的代码) :主要依靠 Esbuild(一个用 Go 语言写的、速度极快的构建工具)。

详细拆解:谁在干活?

为了搞清楚这个问题,我们需要把你写代码时的两个“转换”动作分开看:

1. 动作一:把 Vue 模板变成 JS 代码

也就是刚才我们聊的:v-if -> render 函数。

  • 负责工头Vue Compiler (@vue/compiler-sfc)

  • 工具链:Vite 里的插件 @vitejs/plugin-vue 会调用这个 Vue 编译器。

  • AST 产生地:这里产生的 AST 是 Vue 专有的 Template AST

  • 结论:这块跟 Babel 毫无关系。哪怕你安装了 Babel,Vue 模板编译也不归 Babel 管。

    2. 动作二:把高级 JS/TS 变成浏览器能懂的 JS

比如你用了箭头函数 () => {},或者 TypeScript 的类型标注 name: string,或者最新的 ?. 语法。

  • 传统做法(Webpack 时代)
    这是 Babel 的地盘。Webpack 会用 babel-loader 把这些新语法转成老旧的 ES5 代码,为了兼容 IE 等老浏览器。

  • 现代做法(Vite 时代)
    Vite 默认认为你不需要兼容 IE(除非你专门配置)。现代浏览器(Chrome, Edge, Firefox, Safari)都已经支持 ES6 模块了。

    • 开发环境 (npm run dev)
      Vite 使用 Esbuild 来处理 JS 和 TS。
      Esbuild 比 Babel 快 10-100 倍。因为它不需要把代码转成复杂的 ES5,只需要把 TypeScript 的类型去掉,把极少数浏览器不支持的语法微调一下即可。

    • 生产打包 (npm run build)
      Vite 使用 Rollup 进行打包,同时默认使用 Esbuild 进行代码压缩和转换。

      什么时候 Vite 才会用到 Babel?

虽然 Vite 默认不用 Babel,但在一种情况下它会把 Babel 请回来:

你需要兼容“老古董”浏览器时(比如 IE11 或旧版 Chrome)。 如果你安装了 @vitejs/plugin-legacy 插件:

// vite.config.js
import legacy from '@vitejs/plugin-legacy'

export default {
  plugins: [
    vue(),
    legacy({
      targets: ['ie >= 11'], // 只要你需要支持这些老家伙
      additionalLegacyPolyfills: ['regenerator-runtime/runtime']
    })
  ]
}

这时候,Vite 就会在打包时自动下载并使用 Babel,把你的现代代码狠狠地转译成 ES5,以确保在老浏览器上不报错。

总结对照表

任务 Webpack (Vue CLI) Vite (现代模式)
解析 .vue 模板 Vue Loader (调用 Vue Compiler) @vitejs/plugin-vue (调用 Vue Compiler)
JS 转译 (ES6->ES5) Babel (必装,很慢) Esbuild (内置,极快,不转 ES5)
TS 转译 Babel 或 ts-loader Esbuild (毫秒级完成)
AST 类型 Vue AST + Babel AST Vue AST + Esbuild AST

Vite 项目里:

  1. Vue 编译器 负责把  里的代码变成 render 函数(利用 Vue AST)。

  2. Esbuild 负责把你的 JS/TS 变成浏览器能运行的 JS(不做过度的向下兼容)。

  3. Babel 默认是不存在的,除非你为了兼容性专门请它出山。

    所以,Vite 快的原因之一,就是把“慢吞吞”的 Babel 给优化掉了!


结论

转换成 AST 之后的代码,就是 render 函数(JavaScript 代码)。

怎么让浏览器认识?
因为那已经是纯粹的 JavaScript 了!浏览器执行这段 JS,生成虚拟节点对象,最后 Vue 内部通过 document.createElement 等原生 API 把这些对象变成了屏幕上的像素。

Vue3-父子组件通信

在 Vue 3 中,尤其是在使用 `` 并结合 TypeScript 时,父子组件之间的通信变得非常清晰和类型安全。

核心思想是:

  1. 父组件向子组件 (Prop down): 通过 Props 传递数据。
  2. 子组件向父组件 (Event up): 通过 Events (自定义事件) 发送消息。

1.父传子

  • 定义一些本地数据 (响应式引用 ref)。

  • 将这些数据通过 props 传递给子组件。

  • 监听子组件发出的 emits 事件,并定义一个处理函数来更新本地数据。


  <div class="parent-container">
    <h2>👋 我是父组件</h2>
    
    更新给子组件的消息: 
    

    <p>
      从子组件收到的消息: <strong>{{ messageFromChild }}</strong>
    </p>

    <hr/>

    
  </div>



// 1. 导入 ref 用于创建响应式数据
import { ref } from 'vue';
// 2. 导入子组件
import ChildComponent from './ChildComponent.vue';

// 3. 定义父组件的本地状态
const messageForChild = ref('你好,子组件!这是来自父组件的消息。');
const messageFromChild = ref('...等待子组件的消息...'); // 存放从子组件收到的消息

// 4. 定义一个函数,用于处理子组件触发的 'updateMessage' 事件
// TypeScript 在这里可以根据子组件的 emit 定义自动推断 'newMessage' 的类型为 string
const handleChildMessage = (newMessage: string) => {
  messageFromChild.value = newMessage;
}



.parent-container {
  border: 2px solid #34495e;
  background-color: #f0f8ff;
  padding: 20px;
  border-radius: 8px;
}
input {
  margin-left: 10px;
}

2.子接收数据,传递数据给父组件


  <div class="child-container">
    <h3>👶 我是子组件</h3>
    
    <p>父组件传来的消息 (mainMessage):</p>
    <blockquote>{{ props.mainMessage }}</blockquote>
    
    <p>父组件传来的可选数字 (optionalCount):</p>
    <blockquote>{{ props.optionalCount || '未提供' }}</blockquote>

    
      向父组件发送消息
    
  </div>



// 1. 接收 Props (父 -> 子)
// 使用泛型参数为 props 定义类型。
// 这是最推荐的 TS 语法。
const props = defineProps<{
  mainMessage: string;      // 必需的 prop
  optionalCount?: number;   // 可选的 prop (注意 '?' 符号)
}>()

// 2. 定义 Emits (子 -> 父)
// 使用泛型参数为 emit 定义事件和载荷类型。
const emit = defineEmits<{
  // (e: '事件名', 载荷1: 类型, 载荷2: 类型, ...): void
  (e: 'updateMessage', message: string): void;
}>()

// 3. 定义一个函数来触发 emit
const sendMessageToParent = () => {
  const dataToSend = `你好父组件,我更新了!(时间戳: ${Date.now()})`;
  
  // 4. 触发事件,将数据 (dataToSend) 发送回父组件
  // Vue 会查找父组件上绑定的 @updateMessage 监听器
  emit('updateMessage', dataToSend);
}



.child-container {
  border: 2px dashed #42b983;
  background-color: #f0fff4;
  padding: 15px;
  margin-top: 15px;
  border-radius: 8px;
}
blockquote {
  margin: 0;
  padding: 10px;
  background: #fff;
  border-left: 5px solid #ccc;
}

3.关键点总结

  • defineProps (子组件用于接收)

    • 在 `` 中,它是一个宏,无需导入。
    • 使用泛型 defineProps<{...}>() 来提供最严格的类型检查。
    • props 是单向数据流。子组件不应该直接修改 props 对象中的值。
  • defineEmits (子组件用于发送)

    • 同样是一个宏,无需导入。
    • 使用泛型 defineEmits<{...}>() 来定义事件签名。
    • 语法 (e: 'eventName', payload: Type): void 确保了当你调用 emit('eventName', ...) 时,payload 必须匹配定义的 Type
  • 模板中的绑定

    • 父组件使用冒号 : (即 v-bind) 来传递 props
    • 父组件使用艾特 @ (即 v-on) 来监听 emits 事件。

Pinia 入门:为什么说它是 Vuex 更具魅力的现代继任者?

引言:Vue 状态管理的时代变迁

在 Vue.js 应用开发的世界里,状态管理一直是构建复杂应用的核心课题。多年来,Vuex 作为官方状态管理库,几乎成为 Vue 项目的标准配置。然而,随着 Vue 3 的推出和开发理念的演进,一个全新的挑战者——Pinia——悄然登场,并迅速赢得了开发者的青睐。它不仅被 Vue 官方推荐为“默认的状态管理解决方案”,更以其简洁、直观和强大的特性,正在重新定义我们对 Vue 状态管理的认知。

一、Pinia 是什么?不仅仅是“另一个状态库”

Pinia 是一个为 Vue.js 设计的状态管理库。它由 Vue 核心团队成员开发,旨在提供更直观、类型安全且模块化的状态管理体验。

与 Vuex 相比,Pinia 最显著的哲学转变是:它摒弃了 mutations 的概念。这个设计决策看似简单,却带来了开发体验的质变。

// Pinia 的 Store 示例 - 简洁直观
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++ // 直接修改状态,无需 mutations
    },
  },
  getters: {
    doubleCount: (state) => state.count * 2,
  },
})

二、为什么选择 Pinia?五大核心优势

1. 更简洁的 API 设计

Pinia 的 API 极度精简,学习曲线平缓。去掉了 Vuex 中的 mutations,状态修改可以通过 actions 直接完成,也可以直接赋值修改(配合响应式系统)。这种设计减少了概念数量,让代码更易读易写。

2. 完美的 TypeScript 支持

Pinia 从一开始就为 TypeScript 设计,提供了出色的类型推断。你的 store、state、actions 和 getters 都能获得完整的类型安全,大幅提升开发效率和代码可靠性。

3. 模块化的自然实现

在 Pinia 中,每个 store 都是独立的模块,无需像 Vuex 那样在单一 store 中注册模块。这种设计让代码组织更加灵活,也便于代码分割和按需加载。

4. 更友好的开发体验

  • Composition API 风格:与 Vue 3 的 Composition API 完美契合
  • DevTools 集成:支持 Vue DevTools,提供时间旅行调试
  • 热模块替换:支持开发时的热更新,保持状态不丢失

5. 轻量且高性能

Pinia 体积小巧,同时通过智能的设计避免了不必要的性能开销。它的响应式系统直接构建在 Vue 的 reactive API 之上,确保了高效的状态更新。

三、从 Vuex 迁移到 Pinia:一个平滑的过渡

对于 Vuex 用户,迁移到 Pinia 是一个相对平滑的过程。两者核心概念有相似之处,但 Pinia 提供了更简洁的实现:

特性 Vuex 4 Pinia
状态定义 state 函数 state 函数
获取状态 getters getters
修改状态 mutations + actions actions(可直接修改)
模块系统 嵌套模块 独立 store
TypeScript 支持 需要额外配置 开箱即用

迁移示例:

// Vuex 方式
mutations: {
  SET_COUNT(state, value) {
    state.count = value
  }
},
actions: {
  updateCount({ commit }, value) {
    commit('SET_COUNT', value)
  }
}

// Pinia 方式(更简洁)
actions: {
  updateCount(value) {
    this.count = value // 直接赋值,响应式系统自动处理
  }
}

四、实战:构建你的第一个 Pinia Store

让我们通过一个简单的用户管理示例,快速上手 Pinia:

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

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    isAuthenticated: false,
  }),
  
  actions: {
    async login(credentials) {
      // 模拟 API 调用
      const response = await api.login(credentials)
      this.user = response.user
      this.isAuthenticated = true
      localStorage.setItem('token', response.token)
    },
    
    logout() {
      this.user = null
      this.isAuthenticated = false
      localStorage.removeItem('token')
    },
  },
  
  getters: {
    userName: (state) => state.user?.name || 'Guest',
    isAdmin: (state) => state.user?.role === 'admin',
  },
})

在组件中使用:


  <div>
    <p>Welcome, {{ userStore.userName }}</p>
    Logout
  </div>



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

const userStore = useUserStore()
// 可以直接访问状态和操作

五、高级特性:探索 Pinia 的强大能力

1. Store 组合

Pinia 允许 stores 之间相互调用,便于组织复杂的状态逻辑:

// stores/cart.js
export const useCartStore = defineStore('cart', {
  // ... cart 逻辑
})

// stores/checkout.js  
export const useCheckoutStore = defineStore('checkout', {
  setup() {
    const cartStore = useCartStore()
    
    return {
      // 可以访问其他 store
      cartItems: cartStore.items,
    }
  }
})

2. 插件系统

Pinia 支持插件,可以轻松扩展功能,如持久化存储、日志记录等:

// 持久化插件示例
import { createPinia } from 'pinia'

const pinia = createPinia()

pinia.use(({ store }) => {
  const key = `pinia-${store.$id}`
  const saved = localStorage.getItem(key)
  if (saved) {
    store.$patch(JSON.parse(saved))
  }
  
  store.$subscribe((mutation, state) => {
    localStorage.setItem(key, JSON.stringify(state))
  })
})

六、注意事项与最佳实践

  1. 命名规范:建议使用 use 前缀命名 store(如 useUserStore),以符合 Composition API 惯例
  2. 避免过度使用:不是所有状态都需要放入 Pinia,组件本地状态仍应使用 refreactive
  3. 保持 Actions 纯净:虽然可以直接修改状态,但建议通过 actions 进行修改,便于维护和调试
  4. 利用 DevTools:充分利用 Vue DevTools 的 Pinia 面板进行状态调试

结语:迎接 Vue 状态管理的新时代

Pinia 的出现并非偶然,它代表了 Vue 生态向更简洁、更类型安全、更符合现代开发体验的方向演进。它保留了 Vuex 的核心思想,同时去除了其中的繁复部分,提供了更优雅的解决方案。

对于新项目,Pinia 无疑是默认的最佳选择;对于现有 Vuex 项目,也可以考虑逐步迁移,享受更优的开发体验。正如 Vue 创始人尤雨溪所言:"Pinia 就是 Vuex 5,只不过我们决定叫它 Pinia"。

在状态管理的世界里,没有绝对的"最好",只有"最适合"。而 Pinia,凭借其简洁的设计、出色的 TypeScript 支持和与 Vue 3 的完美融合,正成为越来越多 Vue 开发者的首选。它不仅是一个工具,更是 Vue 生态成熟和进步的标志——让我们以更少的代码,实现更强大的功能,享受更愉快的开发过程。

拥抱 Pinia,就是拥抱 Vue 状态管理的未来。

【AI 编程实战】第 2 篇:让 AI 成为你的前端架构师 - UniApp + Vue3 项目初始化

传统方式搭建前端项目:查文档、试错、调试,耗时 1 天起步。AI 辅助:对话式配置,1 小时搞定。这是《AI 编程实战:TRAE SOLO 全栈开发指南》专栏的第二篇文章,带你用 AI 快速搭建专业级前端项目架构。

一、开篇:为什么项目初始化这么让人头疼?

还记得上一篇文章里提到的小何吗?他在"心动恋聊"项目中遇到的第一个挑战,就是前端项目初始化。

1.1 传统方式的痛点

如果你做过前端项目,一定深有体会:

技术选型迷茫

UniApp vs Taro vs 原生?
Vue3 还是 React?
Vite 还是 Webpack?
UnoCSS 还是 Tailwind CSS?

每个选择背后都是无数的文档、博客、踩坑记录。光是技术选型,就能让人纠结半天。

配置地狱

vite.config.ts
tsconfig.json
uno.config.ts
.eslintrc.js
.prettierrc
pages.json
manifest.json
...

每一个配置文件都有自己的语法、规则、坑点。配置完一个,还有下一个在等着你。

时间黑洞

小何最初的估算:

任务 预计时间
技术调研 2 小时
项目创建 0.5 小时
Vite 配置 1 小时
TypeScript 配置 1 小时
UI 框架集成 2 小时
自动导入配置 1 小时
路由配置 1 小时
环境变量配置 0.5 小时
代码规范配置 2 小时
总计 11 小时

实际上,因为各种问题、版本冲突、配置不兼容,往往需要 1.5-2 天

1.2 AI 辅助的革命性变化

有了 TRAE SOLO,一切都不同了。

小何的实际体验

小何:帮我创建一个 UniApp + Vue3 + TypeScript 项目,
      使用 Vite 构建,支持微信小程序、H5、App 三端,
      集成 UnoCSS 和 UView Pro 组件库

AI:好的,我来帮你搭建完整的项目架构:

    ✓ 分析需求,确定技术选型
    ✓ 创建 Monorepo 项目结构
    ✓ 配置 Vite 构建工具
    ✓ 集成 TypeScript 严格模式
    ✓ 配置 UnoCSS 原子化 CSS
    ✓ 集成 UView Pro 组件库
    ✓ 设置自动导入
    ✓ 配置多环境变量
    ✓ 添加代码规范工具

    项目结构已生成,请查看...

一天的工作,不到一小时完成。 这就是 AI 辅助开发的魔力。

二、技术选型:让 AI 帮你做决策

2.1 框架对比分析

项目开始前,小何需要确定技术选型。传统做法是搜索各种博客文章、技术对比,然后纠结半天。

TRAE SOLO 的做法

小何:帮我对比 UniApp、Taro、原生小程序的优劣,
      项目需求:社交类小程序,需要支持微信小程序、H5、App 三端

AI:(通过 MCP 实时查阅最新文档)

      分析结论:

      1. UniApp 优势:
         - 一套代码,支持微信/H5/App/鸿蒙等多端
         - Vue3 技术栈,学习成本低
         - 生态完善,组件库丰富
         - 文档完善,社区活跃

      2. Taro 优势:
         - React 技术栈友好
         - 适合已有 React 经验的团队

      3. 原生小程序:
         - 性能最优
         - 但只能在单一平台运行

      推荐:UniApp + Vue3

      理由:
      1. 项目需要三端发布,UniApp 跨端能力最强
      2. 社交类应用迭代快,UniApp 开发效率高
      3. 后续还要扩展到鸿蒙、安卓、iOS,UniApp 支持最全

5 分钟,得到了专业级的技术选型分析报告。

2.2 构建工具选择

小何:UniApp 应该用 Vite 还是 Webpack?

AI:强烈推荐 Vite!

      对比分析:
      ┌────────────┬────────────────┬────────────────┐
      │   维度     │     Vite       │    Webpack     │
      ├────────────┼────────────────┼────────────────┤
      │ 冷启动     │ 毫秒级         │ 秒级           │
      │ 热更新     │ 即时           │ 有延迟         │
      │ 配置复杂度 │ 简单           │ 复杂           │
      │ 生态支持   │ 完善           │ 非常完善       │
      │ 构建速度   │ 快             │ 较慢           │
      └────────────┴────────────────┴────────────────┘

      实测数据(心动恋聊项目):
      - Vite 冷启动:1.2 秒
      - Webpack 冷启动:8.5 秒

      结论:Vite 开发体验完胜,选它!

2.3 最终技术栈确定

通过 AI 辅助分析,"心动恋聊"项目确定了以下技术栈:

前端技术栈:
├── 框架:UniApp + Vue 3.4.21
├── 构建工具:Vite 5.2.8
├── 语言:TypeScript 5.7
├── 状态管理:Pinia 2.0.36 + 持久化
├── 原子化 CSS:UnoCSS 66.0
├── UI 组件库:UView Pro
├── HTTP 请求:自定义封装(基于 uni.request)
├── 列表优化:z-paging 2.8.7
└── 数据请求:@tanstack/vue-query

三、项目初始化:AI 一键生成

3.1 Monorepo 项目结构

"心动恋聊"采用 Monorepo 架构,这是现代前端项目的最佳实践。

提示词

帮我创建一个 Monorepo 项目结构,包含:
1. apps/unibest-mp - UniApp 小程序端
2. apps/xindong-server - Next.js 服务端
3. packages/shared-types - 共享类型定义
使用 pnpm 管理依赖,Turborepo 编排任务

AI 生成的项目结构

xingdong/
├── apps/
│   ├── unibest-mp/              # UniApp 多端应用
│   │   ├── src/
│   │   │   ├── api/             # API 接口定义
│   │   │   ├── components/      # 公共组件
│   │   │   ├── composables/     # 组合式函数
│   │   │   ├── config/          # 配置文件
│   │   │   ├── hooks/           # 自定义 Hooks
│   │   │   ├── http/            # HTTP 请求封装
│   │   │   ├── layouts/         # 布局组件
│   │   │   ├── pages/           # 页面文件
│   │   │   ├── store/           # Pinia 状态管理
│   │   │   ├── subPackages/     # 分包页面
│   │   │   │   ├── agreement/   # 协议相关
│   │   │   │   ├── vip/         # 会员相关
│   │   │   │   └── planForm/    # 计划表单
│   │   │   ├── tabbar/          # 底部导航
│   │   │   └── utils/           # 工具函数
│   │   ├── env/                 # 环境变量
│   │   ├── vite.config.ts       # Vite 配置
│   │   └── package.json
│   │
│   └── xingdong-server/         # Next.js 服务端
│       ├── src/
│       │   └── app/             # App Router
│       └── package.json
│
├── packages/
│   └── shared-types/            # 共享类型定义
│       ├── src/
│       │   └── index.ts
│       └── package.json
│
├── pnpm-workspace.yaml          # pnpm 工作区配置
├── turbo.json                   # Turborepo 配置
└── package.json                 # 根配置

pnpm-workspace.yaml

packages:
  - apps/*
  - packages/*

turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["apps/unibest-mp/dist/**", "apps/xingdong-server/.next/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

这个结构的好处:

  1. 代码复用:shared-types 包让前后端类型一致
  2. 独立开发:前后端可以独立启动、独立部署
  3. 并行构建:Turborepo 自动并行执行不相关的任务

3.2 配置 Vite

Vite 配置是前端项目的核心。让我们看看 AI 生成的实际配置:

提示词

帮我配置 UniApp 的 Vite 配置文件,需要:
1. 自动导入 Vue API
2. 自动导入 uni-app API
3. 自动导入自定义 hooks
4. 配置路径别名
5. 支持多环境变量
6. 集成 UnoCSS
7. 配置分包优化

生成的 vite.config.ts(实际项目配置)

// vite.config.ts 核心配置片段
export default ({ command, mode }) => {
  // ... 环境变量加载逻辑 ...

  return defineConfig({
    plugins: [
      // 1. 页面路由自动生成
      UniPages({
        exclude: ['**/components/**/**.*'],
        subPackages: [
          'src/subPackages/agreement',
          'src/subPackages/vip',
          'src/subPackages/planForm',
        ],
      }),

      // 2. 布局系统 & 平台适配
      UniLayouts(),
      UniPlatform(),
      UniManifest(),

      // 3. 修复 Vue 编译问题(AI 自动生成的补丁)
      {
        name: 'fix-vite-plugin-vue',
        configResolved(config) {
          const plugin = config.plugins.find((p) => p.name === 'vite:vue');
          if (plugin && plugin.api && plugin.api.options) {
            plugin.api.options.devToolsEnabled = false;
          }
        },
      },

      // 4. UnoCSS 原子化 CSS
      UnoCSS(),

      // 5. 自动导入
      AutoImport({
        imports: ['vue', 'uni-app'],
        dirs: ['src/hooks'],
        vueTemplate: true,
      }),

      // ... 其他插件
      Uni(),
    ],
    // ...
  });
};

这个配置包含了

  1. @uni-helper 插件生态:页面路由、布局、平台、manifest 全自动化
  2. UnoCSS:原子化 CSS,开发效率翻倍
  3. AutoImport:Vue API、uni-app API、自定义 hooks 自动导入
  4. Hack 修复:AI 甚至帮我生成了一个 fix-vite-plugin-vue 插件来解决特定的编译 Bug,这在传统开发中可能要排查半天。

3.3 TypeScript 配置

提示词

帮我配置 TypeScript 严格模式,确保类型安全,支持 Vue3 和 uni-app

生成的配置

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "jsxImportSource": "vue",
    "paths": {
      "@/*": ["./src/*"],
      "@img/*": ["./src/static/images/*"]
    },
    "types": ["@dcloudio/types", "@uni-helper/uni-types", "miniprogram-api-typings"]
  },
  "include": ["src/**/*.ts", "src/**/*.vue"],
  "exclude": ["node_modules", "dist"]
}

四、UI 框架集成:UnoCSS + UView Pro

4.1 UnoCSS 配置:AI 也会"翻车"

UnoCSS 是原子化 CSS 的最佳选择,但在小程序环境中使用时,AI 一开始给出的配置并不完美。

第一次尝试

小何:配置 UnoCSS
AI:生成了标准的 Web 端配置(presetUno)。
结果:小程序端样式完全不生效。

修正后的交互

小何:这个配置在微信小程序里不生效,请检查是否需要专用预设?

AI:抱歉,你是对的。在 UniApp 中需要使用 `@uni-helper/unocss-preset-uni`。
    我已更新配置:

最终生成的 uno.config.ts

import { defineConfig, presetIcons } from 'unocss';
import { presetUni } from '@uni-helper/unocss-preset-uni';

export default defineConfig({
  presets: [
    // 使用 UniApp 专用预设,解决小程序兼容性问题
    presetUni({
      attributify: {
        prefixedOnly: true, // 避免属性冲突
      },
    }),
    presetIcons({
      scale: 1.2,
      warn: true,
    }),
  ],
  theme: {
    colors: {
      // 使用 CSS 变量,支持动态换肤
      primary: 'var(--wot-color-theme,#0957DE)',
    },
  },
});

这里的重点是 presetUniprefixedOnly,这是 AI 在被指出错误后迅速修正的关键点。

UnoCSS 的优势

<!-- 传统 CSS 写法 -->
<template>
  <view class="container">
    <text class="title">心动恋聊</text>
  </view>
</template>

<style scoped>
.container {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 16px;
}
.title {
  font-size: 24px;
  font-weight: bold;
  color: #ff6b9d;
}
</style>

<!-- UnoCSS 写法 -->
<template>
  <view class="flex-center p-4">
    <text class="text-24px font-bold text-primary">心动恋聊</text>
  </view>
</template>

代码量减少 70%,开发效率大幅提升!

4.2 UView Pro 集成

UView Pro 是 UniApp 最好用的组件库之一。

提示词

帮我集成 UView Pro 组件库,配置按需导入和主题定制

package.json 依赖

{
  "dependencies": {
    "uview-pro": "^0.0.3"
  }
}

组件使用示例

<template>
  <view class="page">
    <!-- 导航栏 -->
    <u-navbar title="心动恋聊" :placeholder="true" />

    <!-- 表单 -->
    <u-form ref="formRef" :model="formData" :rules="rules">
      <u-form-item label="昵称" prop="nickname">
        <u-input v-model="formData.nickname" placeholder="请输入昵称" />
      </u-form-item>
      <u-form-item label="性别" prop="gender">
        <u-radio-group v-model="formData.gender">
          <u-radio label="男" :name="1" />
          <u-radio label="女" :name="2" />
        </u-radio-group>
      </u-form-item>
    </u-form>

    <!-- 按钮 -->
    <u-button type="primary" @click="handleSubmit">提交</u-button>

    <!-- 弹窗 -->
    <u-popup v-model:show="showPopup" mode="bottom">
      <view class="p-4">弹窗内容</view>
    </u-popup>
  </view>
</template>

<script setup lang="ts">
const formRef = ref();
const showPopup = ref(false);

const formData = reactive({
  nickname: '',
  gender: 1,
});

const rules = {
  nickname: [{ required: true, message: '请输入昵称' }],
  gender: [{ required: true, message: '请选择性别' }],
};

const handleSubmit = async () => {
  const valid = await formRef.value.validate();
  if (valid) {
    // 提交逻辑
  }
};
</script>

五、自动导入配置:提升开发体验

5.1 Vue API 自动导入

传统写法每个文件都要导入:

// 传统写法
import { ref, reactive, computed, watch, onMounted } from 'vue';

const count = ref(0);
const state = reactive({ name: '' });

配置自动导入后:

// 自动导入后,直接使用
const count = ref(0);
const state = reactive({ name: '' });
const double = computed(() => count.value * 2);

onMounted(() => {
  console.log('mounted');
});

AutoImport 配置

AutoImport({
  imports: ['vue', 'uni-app'],
  dts: 'src/types/auto-import.d.ts',
  dirs: ['src/hooks'],
  vueTemplate: true,
});

这会自动生成类型声明文件 src/types/auto-import.d.ts

// Auto generated by unplugin-auto-import
export {};
declare global {
  const computed: (typeof import('vue'))['computed'];
  const onMounted: (typeof import('vue'))['onMounted'];
  const onUnmounted: (typeof import('vue'))['onUnmounted'];
  const reactive: (typeof import('vue'))['reactive'];
  const ref: (typeof import('vue'))['ref'];
  const watch: (typeof import('vue'))['watch'];
  // uni-app APIs
  const onLaunch: (typeof import('uni-app'))['onLaunch'];
  const onShow: (typeof import('uni-app'))['onShow'];
  const onHide: (typeof import('uni-app'))['onHide'];
  // ... 更多
}

5.2 自定义 Hooks 自动导入

src/hooks/ 目录下创建的 hooks 也会自动导入:

src/hooks/useRequest.ts

import type { Ref } from 'vue';

interface UseRequestOptions<T> {
  immediate?: boolean;
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
}

export function useRequest<T>(fn: () => Promise<T>, options: UseRequestOptions<T> = {}) {
  const { immediate = false, onSuccess, onError } = options;

  const data: Ref<T | null> = ref(null);
  const loading = ref(false);
  const error = ref<Error | null>(null);

  const execute = async () => {
    loading.value = true;
    error.value = null;
    try {
      data.value = await fn();
      onSuccess?.(data.value);
    } catch (e) {
      error.value = e as Error;
      onError?.(error.value);
    } finally {
      loading.value = false;
    }
  };

  if (immediate) {
    execute();
  }

  return { data, loading, error, execute };
}

使用时无需导入

<script setup lang="ts">
// 直接使用,无需 import
const { data, loading, execute } = useRequest(() => apiGetUserInfo(), { immediate: true });
</script>

5.3 组件自动注册

配置 @uni-helper/vite-plugin-uni-components 后,组件也自动注册:

Components({
  extensions: ['vue'],
  deep: true,
  directoryAsNamespace: false,
  dts: 'src/types/components.d.ts',
});

src/components/UserCard.vue

<template>
  <view class="user-card">
    <image :src="user.avatar" class="avatar" />
    <text class="name">{{ user.name }}</text>
  </view>
</template>

<script setup lang="ts">
defineProps<{
  user: {
    avatar: string;
    name: string;
  };
}>();
</script>

使用时无需注册

<template>
  <!-- 直接使用,无需 import 和 components 注册 -->
  <UserCard :user="userInfo" />
</template>

六、路由配置:pages.json 优化

6.1 页面路由自动生成

使用 @uni-helper/vite-plugin-uni-pages,页面路由可以自动生成。

在页面文件中配置路由

<!-- src/pages/index/index.vue -->
<route lang="json">
{
  "style": {
    "navigationBarTitleText": "首页"
  }
}
</route>

<template>
  <view class="page">
    <!-- 页面内容 -->
  </view>
</template>

自动生成的 pages.json

{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "首页"
      }
    },
    {
      "path": "pages/my/my",
      "style": {
        "navigationBarTitleText": "我的"
      }
    }
  ],
  "subPackages": [
    {
      "root": "subPackages/agreement",
      "pages": [
        {
          "path": "privacy",
          "style": { "navigationBarTitleText": "隐私协议" }
        },
        {
          "path": "user-agreement",
          "style": { "navigationBarTitleText": "用户协议" }
        }
      ]
    },
    {
      "root": "subPackages/vip",
      "pages": [
        {
          "path": "index",
          "style": { "navigationBarTitleText": "会员中心" }
        }
      ]
    }
  ],
  "tabBar": {
    "color": "#999999",
    "selectedColor": "#FF6B9D",
    "backgroundColor": "#ffffff",
    "list": [
      {
        "pagePath": "pages/index/index",
        "text": "首页",
        "iconPath": "static/tabbar/home.png",
        "selectedIconPath": "static/tabbar/home-active.png"
      },
      {
        "pagePath": "pages/my/my",
        "text": "我的",
        "iconPath": "static/tabbar/my.png",
        "selectedIconPath": "static/tabbar/my-active.png"
      }
    ]
  },
  "globalStyle": {
    "navigationBarTextStyle": "black",
    "navigationBarTitleText": "心动恋聊",
    "navigationBarBackgroundColor": "#ffffff",
    "backgroundColor": "#F5F5F5"
  }
}

6.2 分包配置

小程序有包体积限制(主包 2MB,总包 20MB),分包是必须的。

配置分包

// vite.config.ts
UniPages({
  exclude: ['**/components/**/**.*'],
  dts: 'src/types/uni-pages.d.ts',
  subPackages: [
    'src/subPackages/agreement', // 协议相关页面
    'src/subPackages/vip', // 会员相关页面
    'src/subPackages/planForm', // 计划表单页面
  ],
});

分包优化插件

// @uni-ku/bundle-optimizer 自动优化
Optimization({
  enable: {
    optimization: true, // 开启优化
    'async-import': true, // 异步导入
    'async-component': true, // 异步组件
  },
  dts: {
    base: 'src/types',
  },
});

七、环境变量配置

7.1 多环境配置

"心动恋聊"项目支持多环境、多项目配置:

目录结构

env/
├── .env                           # 基础配置
├── .env.development               # 开发环境
└── .env.production                # 生产环境

.env.development

 # 基础配置
 VITE_APP_PORT=5173
 VITE_APP_PUBLIC_BASE=/

 # API 配置
 VITE_SERVER_BASEURL=http://localhost:3000
 VITE_APP_PROXY=true

 # 业务配置
 VITE_APP_SOURCE_ID=your_source_id
 VITE_APP_CHANNEL_ID=weixin

7.2 环境变量使用

在代码中使用

// 直接使用
const apiBaseUrl = import.meta.env.VITE_SERVER_BASEURL;
const sourceId = import.meta.env.VITE_APP_SOURCE_ID;

// 类型安全
interface ImportMetaEnv {
  VITE_APP_PORT: string;
  VITE_SERVER_BASEURL: string;
  VITE_APP_SOURCE_ID: string;
  VITE_APP_CHANNEL_ID: string;
  VITE_APP_BRAND_KEY: string;
}

启动命令

{
  "scripts": {
    "dev:mp": "uni -p mp-weixin --mode development",
    "build:mp": "uni build -p mp-weixin --mode production"
  }
}

八、代码规范配置

8.1 ESLint 配置

提示词

配置 ESLint,适合 Vue3 + TypeScript + UniApp 项目

使用 @uni-helper/eslint-config

// eslint.config.js
import uniHelper from '@uni-helper/eslint-config';

export default uniHelper({
  typescript: true,
  vue: true,
  unocss: true,
});

8.2 Prettier 配置

// .prettierrc
{
  "printWidth": 100,
  "singleQuote": true,
  "trailingComma": "all",
  "semi": true,
  "tabWidth": 2,
  "endOfLine": "lf"
}

8.3 Git Hooks

// package.json
{
  "lint-staged": {
    "*": "eslint --fix"
  }
}

九、实战演示:首页核心逻辑开发

"心动恋聊"的首页并不是一个简单的展示页,它包含了一个复杂的交互逻辑:文字/图片输入模式切换

9.1 需求描述

小何:首页需要一个输入区域,支持两种模式:
1. 文字模式:输入对方说的话。
2. 图片模式:上传聊天截图。

切换模式时,输入框和上传区域要互斥显示。
并且需要一个"清除记忆"的功能,但只在文字模式下显示。

9.2 AI 的逐步实现

这是一个涉及状态管理、UI 交互和业务逻辑的复杂需求。AI 是如何一步步实现的呢?

第一步:定义状态

AI 首先定义了核心的响应式状态:

// src/pages/index/index.vue
const showUploadArea = ref(false); // 控制模式切换
const inputText = ref(''); // 文字输入
const selectedImages = ref([]); // 图片列表

// 切换模式逻辑
const toggleInputMode = () => {
  showUploadArea.value = !showUploadArea.value;
  // 切换时清空状态,避免数据混淆
  if (showUploadArea.value) {
    inputText.value = '';
  } else {
    selectedImages.value = [];
  }
};

第二步:构建动态 UI

利用 Vue 的 v-if/v-show 指令处理复杂的显示逻辑:

<template>
  <!-- 模式切换按钮 -->
  <view class="flex justify-between items-center">
    <text>{{ showUploadArea ? '与Ta的聊天截图:' : 'Ta说:' }}</text>
    <view @click="toggleInputMode">
      <text>{{ showUploadArea ? '切换文字' : '切换图片' }}</text>
    </view>
  </view>

  <!-- 上传区域 (图片模式) -->
  <view v-show="showUploadArea" class="upload-area">
    <ImageGrid :images="selectedImages" @add="addImages" />
  </view>

  <!-- 输入框 (文字模式) -->
  <view v-show="!showUploadArea" class="input-wrapper">
    <u-input type="textarea" v-model="inputText" :autoHeight="true" />
  </view>

  <!-- 清除记忆 (仅文字模式) -->
  <view v-if="!showUploadArea" class="clear-memory-btn" @click="handleClearMemory">
    <text>清除记忆</text>
  </view>
</template>

第三步:处理业务逻辑

生成回复时,需要根据当前模式调用不同的接口:

const handleGenerate = async () => {
  // 1. 鉴权检查
  if (!(await checkAuth())) return;

  // 2. 模式判断
  if (showUploadArea.value) {
    // 图片模式逻辑
    if (selectedImages.value.length === 0) return toast.info('请上传截图');
    await generateByImage(selectedImages.value);
  } else {
    // 文字模式逻辑
    if (!inputText.value.trim()) return toast.info('请输入内容');
    // 调用带记忆的接口
    await generateByText({
      text: inputText.value,
      sessionId: chatSessionStore.sessionId,
    });
  }
};

9.3 真实代码片段

最终生成的代码不仅逻辑清晰,还处理了很多细节,比如 iOS 的样式适配:

// 适配 iOS 的输入框样式
const inputCustomStyle = computed(() => {
  const isIOS = systemInfo?.platform === 'ios';
  return {
    padding: isIOS ? '12rpx' : '20rpx',
    minHeight: '240rpx',
    borderRadius: '12px',
  };
});

这就是 AI 辅助开发的威力:它不仅能写出跑通的代码,还能考虑到平台差异和边界情况。

9.3 运行测试

# 微信小程序
pnpm --filter unibest-mp dev:mp

# H5
pnpm --filter unibest-mp dev:h5

# App
pnpm --filter unibest-mp dev:app

十、总结与下一步

10.1 本篇完成的工作

通过 AI 辅助,我们在 不到 1 小时 内完成了:

任务 完成情况
✅ Monorepo 项目结构 标准的 pnpm + Turborepo 架构
✅ Vite 配置 完整的插件链和优化配置
✅ TypeScript 配置 严格模式,完整类型支持
✅ UnoCSS 集成 原子化 CSS,主题定制
✅ UView Pro 集成 组件库完整接入
✅ 自动导入配置 Vue API、uni-app API、Hooks
✅ 路由系统 自动生成,分包优化
✅ 环境变量 多环境、多项目支持
✅ 代码规范 ESLint + Prettier
✅ 首页开发 完整的页面实现

10.2 核心提示词模板

项目初始化

创建 [框架] + [技术栈] 项目,
包含 [目录结构],
配置 [构建工具]

配置文件生成

配置 [工具名称],需要:
1. [功能点 1]
2. [功能点 2]
3. [功能点 3]

页面生成

创建 [页面名称],包括:
- [功能描述]
使用 [UI 框架] 和 [样式方案]

10.4 下一篇预告

《【AI 编程实战】第 3 篇:AI 辅助后端开发 - Next.js 15 API 快速搭建》

我们将学习:

  • Next.js 15 App Router 架构
  • Prisma ORM 数据库操作
  • RESTful API 设计
  • JWT 认证中间件
  • 前后端类型共享

关注我,不错过每一篇实战干货!


如果这篇文章对你有帮助,请点赞、收藏、转发,让更多人了解 AI 编程的强大!

有任何问题,欢迎在评论区留言,我们一起讨论。

vxe-tree 树组件拖拽排序功能的使用教程

vxe-tree 树组件拖拽排序功能的使用教程,通过 drag 启用行拖拽排序功能,支持同层级、跨层级、拖拽到子级非常强大的拖拽功能等

官网:vxeui.com github:github.com/x-extends/v… gitee:gitee.com/x-extends/v…

同层级拖拽

通过 drag-config.isPeerDrag 启用同层级拖拽

image

<template>
  <div>
    <vxe-tree v-bind="treeOptions"></vxe-tree>
  </div>
</template>

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

const treeOptions = reactive({
  transform: true,
  drag: true,
  dragConfig: {
    isPeerDrag: true
  },
  data: [
    { title: '节点2', id: '2', parentId: null },
    { title: '节点3', id: '3', parentId: null },
    { title: '节点3-1', id: '31', parentId: '3' },
    { title: '节点3-2', id: '32', parentId: '3' },
    { title: '节点3-2-1', id: '321', parentId: '32' },
    { title: '节点3-2-2', id: '322', parentId: '32' },
    { title: '节点3-3', id: '33', parentId: '3' },
    { title: '节点3-3-1', id: '331', parentId: '33' },
    { title: '节点3-3-2', id: '332', parentId: '33' },
    { title: '节点3-3-3', id: '333', parentId: '33' },
    { title: '节点3-4', id: '34', parentId: '3' },
    { title: '节点4', id: '4', parentId: null },
    { title: '节点4-1', id: '41', parentId: '4' },
    { title: '节点4-1-1', id: '411', parentId: '42' },
    { title: '节点4-1-2', id: '412', parentId: '42' },
    { title: '节点4-2', id: '42', parentId: '4' },
    { title: '节点4-3', id: '43', parentId: '4' },
    { title: '节点4-3-1', id: '431', parentId: '43' },
    { title: '节点4-3-2', id: '432', parentId: '43' },
    { title: '节点5', id: '5', parentId: null }
  ]
})
</script>

跨层级拖拽

通过 drag-config.isCrossDrag 启用跨层级拖拽

tree_drag_coss_drag

<template>
  <div>
    <vxe-tree v-bind="treeOptions"></vxe-tree>
  </div>
</template>

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

const treeOptions = reactive({
  transform: true,
  drag: true,
  dragConfig: {
    isCrossDrag: true
  },
  data: [
    { title: '节点2', id: '2', parentId: null },
    { title: '节点3', id: '3', parentId: null },
    { title: '节点3-1', id: '31', parentId: '3' },
    { title: '节点3-2', id: '32', parentId: '3' },
    { title: '节点3-2-1', id: '321', parentId: '32' },
    { title: '节点3-2-2', id: '322', parentId: '32' },
    { title: '节点3-3', id: '33', parentId: '3' },
    { title: '节点3-3-1', id: '331', parentId: '33' },
    { title: '节点3-3-2', id: '332', parentId: '33' },
    { title: '节点3-3-3', id: '333', parentId: '33' },
    { title: '节点3-4', id: '34', parentId: '3' },
    { title: '节点4', id: '4', parentId: null },
    { title: '节点4-1', id: '41', parentId: '4' },
    { title: '节点4-1-1', id: '411', parentId: '42' },
    { title: '节点4-1-2', id: '412', parentId: '42' },
    { title: '节点4-2', id: '42', parentId: '4' },
    { title: '节点4-3', id: '43', parentId: '4' },
    { title: '节点4-3-1', id: '431', parentId: '43' },
    { title: '节点4-3-2', id: '432', parentId: '43' },
    { title: '节点5', id: '5', parentId: null }
  ]
})
</script>

gitee.com/x-extends/v…

vue里的ref和reactive的工作原理是什么?以及它们的区别是什么?为什么有人说reactive“废弃”了?

这是一个非常硬核且高质量的问题。要理解 Vue 3 的响应式系统,必须深入到源码层面。

首先,我需要纠正一个重要的误区:Vue 3 并没有“弃用” reactive 。它仍然是 Vue 3 核心 API 的一部分。但是,确实存在一种趋势(包括 Vue 官方团队的建议),推荐优先使用 ref 。

下面我将从源码级别(基于 Vue 3 packages/reactivity 模块)为你详细剖析两者的原理、区别,以及为什么现在大家更倾向于用 ref


一、 reactive 的源码工作原理

reactive 的核心是利用了 ES6 的 Proxy 对象。它拦截对象的操作(增删改查),从而实现依赖收集和触发更新。

1. 核心流程 (reactive.ts)

当你调用 reactive(obj) 时,Vue 内部实际上执行了 createReactiveObject 函数。

简化版源码逻辑:

// 存放代理对象的缓存,防止同一个对象被代理多次
const reactiveMap = new WeakMap();

function createReactiveObject(target) {
  // 1. 如果不是对象(是基础类型),直接返回,无法代理
  if (!isObject(target)) {
    return target;
  }

  // 2. 检查缓存,如果已经代理过,直接返回缓存的 Proxy
  if (reactiveMap.has(target)) {
    return reactiveMap.get(target);
  }

  // 3. 创建 Proxy
  const proxy = new Proxy(target, mutableHandlers);

  // 4. 存入缓存
  reactiveMap.set(target, proxy);
  
  return proxy;
}

2. 拦截器 (baseHandlers.ts)

Proxy 的威力在于第二个参数 mutableHandlers。它定义了 get(读取)和 set(修改)的拦截行为。

  • get (依赖收集) :当副作用函数(Effect,如 computed 或 render)读取属性时,触发 track 函数,将当前 Effect 记录下来。
  • set (派发更新) :当修改属性时,触发 trigger 函数,找到之前收集的 Effect 并执行它们。

简化版 Handler 逻辑:

const mutableHandlers = {
  get(target, key, receiver) {
    // 1. 收集依赖
    track(target, key);
    
    // 2. 获取原本的值
    const res = Reflect.get(target, key, receiver);

    // 3. 【深度响应关键点】如果获取到的 res 是对象,递归将其转为 reactive
    // 这与 Vue 2 不同,Vue 3 是懒代理(访问时才代理),性能更好
    if (isObject(res)) {
      return reactive(res); 
    }
    
    return res;
  },
  
  set(target, key, value, receiver) {
    // 1. 获取旧值
    const oldValue = target[key];
    // 2. 设置新值
    const result = Reflect.set(target, key, value, receiver);
    
    // 3. 如果值发生变化,触发更新
    if (hasChanged(value, oldValue)) {
      trigger(target, key);
    }
    
    return result;
  }
};


二、 ref 的源码工作原理

ref 的设计初衷是为了解决 基本数据类型(Primitives) 无法使用 Proxy 代理的问题(Proxy 只能代理对象)。

1. 核心流程 (ref.ts)

ref 本质上是一个 对象的包装器。它通过定义一个类 RefImpl,利用 ES6 的类属性访问器(getter/setter)来拦截 .value 的访问。

简化版源码逻辑:

function ref(value) {
  return createRef(value);
}

function createRef(rawValue) {
  if (isRef(rawValue)) return rawValue;
  return new RefImpl(rawValue);
}

class RefImpl {
  public _value; // 存储当前值
  public _rawValue; // 存储原始值(用于比较)
  public dep; // 依赖容器
  public __v_isRef = true; // 标记这是一个 Ref

  constructor(value) {
    this._rawValue = value;
    // 核心差异点:如果传入的是对象,内部会自动调用 reactive()!
    this._value = isObject(value) ? reactive(value) : value;
  }

  get value() {
    // 1. 收集依赖
    trackRefValue(this);
    return this._value;
  }

  set value(newVal) {
    // 2. 检查值是否变化
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal;
      // 如果新赋的值是对象,再次转为 reactive
      this._value = isObject(newVal) ? reactive(newVal) : newVal;
      // 3. 触发更新
      triggerRefValue(this);
    }
  }
}

重点总结

  • 如果 ref(10):利用 RefImpl 的 get value 和 set value 进行拦截。
  • 如果 ref({ count: 1 })RefImpl 会将内部的 value 变成 reactive({ count: 1 }) 的 Proxy 对象。

三、 Ref 与 Reactive 的关键区别

特性 Ref Reactive
数据类型 支持所有类型(基本类型 + 对象)。 仅支持对象(Array, Object, Map, Set)。
底层原理 RefImpl 类(getter/setter)。如果是对象,内部转调 reactive 直接使用 Proxy
访问方式 必须通过 .value 访问(模板中自动解包除外)。 直接访问属性。
重新赋值 myRef.value = {} 依然保持响应式。 let state = reactive({}); state = {} 会丢失响应性
结构解构 解构会丢失响应性(需用 toRefs)。 解构会丢失响应性(需用 toRefs)。

四、 为什么说 Vue 3 “想弃用” reactive(实际上是推荐 ref)?

这是一个由 “开发者体验(DX)” 驱动的趋势。虽然 reactive 并没有被官方删除,但社区和尤雨溪(Evan You)都倾向于 “Ref 一把梭” ,主要原因如下:

1. reactive 的局限性会导致 Bug

Vue 新手最常遇到的坑就是 reactive 丢失响应性:

  • 赋值替换问题

    let list = reactive([]);
    // 错误!这样赋值会切断 Proxy 的连接,页面不会更新
    list = [1, 2, 3]; 
    
    // 正确写法(很麻烦)
    list.push(...[1, 2, 3]);
    // 或者再包一层
    const state = reactive({ list: [] });
    state.list = [1, 2, 3];
    

    而 ref 没有这个问题:list.value = [1, 2, 3] 总是安全的。

  • 解构丢失问题

    const state = reactive({ count: 0 });
    let { count } = state; // count 变成了普通数字,不再是响应式的
    

2. 类型不一致带来的心智负担

在一个组件中,如果混用两者:

  • 有些变量直接读(reactive)。
  • 有些变量要加 .value(ref)。
  • 有些对象是通过 props 传进来的,你不知道它是普通对象还是 Proxy。

使用 ref 可以统一心智模型

  • 在 JS 代码中,凡是响应式数据,都要带 .value 。这种显式调用虽然写起来多几个字符,但让代码更清晰,更容易区分哪些是普通变量,哪些是响应式变量。

3. 官方的态度

尤雨溪曾在社区讨论中表示,如果 Vue 3 重新设计,也许会只保留 ref。虽然 reactive 为了 API 的完整性被保留了,但在实际业务开发中,全量使用 ref 是目前最推荐的最佳实践。

总结

  • Reactive: 基于 Proxy,只对对象有效,存在解构和重新赋值丢失响应性的风险。
  • Ref: 基于 class 的 getter/setter,统一了基本类型和对象的处理(对象内部调用 reactive)。
  • 结论: 建议在项目中优先使用 ref 。它更加稳健,不易出错,且能够清晰地标识出“这是一个响应式数据”。

详细说说vue2何vue3的工作原理以及区别?为什么vue3会做此升级?

这是一个非常有分量的架构级问题。要讲清楚 Vue 2 到 Vue 3 的演进,我们需要从底层的响应式原理、虚拟 DOM (Virtual DOM) 的编译策略以及运行时性能三个维度深入剖析。

以下是从源码和架构设计层面的详细解读:


一、 什么是虚拟 DOM (Virtual DOM)?

在理解 Vue 原理之前,必须先理解虚拟 DOM。

1. 定义:
虚拟 DOM 本质上是一个 JavaScript 对象(VNode) ,它是真实 DOM 的“蓝图”或“替身”。

2. 为什么需要它?

  • 操作真实 DOM 代价高昂:真实 DOM 节点非常重(包含大量属性和事件)。频繁操作 DOM 会导致浏览器频繁重排(Reflow)和重绘(Repaint),性能极差。
  • JS 计算很快:在 JS 层面通过对比两个对象(新旧 VNode)的差异(Diff),计算出最小的更变操作,然后再去更新真实 DOM,效率最高。

3. 结构示例:

// 真实 DOM: <div class="box">Hello</div>
// 虚拟 DOM (VNode):
const vnode = {
  tag: 'div',
  props: { class: 'box' },
  children: 'Hello',
  // Vue3 新增了 patchFlag 等编译优化标记
}


二、 Vue 2 的工作原理

Vue 2 的核心是 Options API 和基于 Object.defineProperty 的响应式系统。

1. 响应式原理 (Reactivity)

Vue 2 在初始化(initState)时,会递归遍历 data 中的所有属性。

  • 核心 APIObject.defineProperty

  • 源码逻辑

    • Observer(观察者) :递归把对象属性转为 getter/setter。
    • Dep(依赖容器) :每个属性闭包里都有一个 Dep 实例,用来存放到到底谁用了我。
    • Watcher(订阅者) :组件渲染函数、computed、watch 都是 Watcher。
// Vue 2 响应式简化版
Object.defineProperty(obj, key, {
  get() {
    // 1. 依赖收集:如果当前有正在计算的 Watcher,就把它收集进 Dep
    if (Dep.target) dep.depend();
    return value;
  },
  set(newVal) {
    if (newVal === value) return;
    value = newVal;
    // 2. 派发更新:通知 Dep 里所有的 Watcher 去 update
    dep.notify();
  }
});

2. Vue 2 的痛点

  1. 初始化慢:因为是递归遍历,如果 data 对象很大,启动(Init)阶段会非常耗时,且内存占用高。
  2. 动态性不足:无法监听对象属性的新增(add)和删除(delete),必须用 $set / $delete
  3. 数组限制:无法拦截数组索引修改(arr[0] = 1),Vue 2 重写了数组的 7 个变异方法(push, pop...)来实现响应式。

3. 虚拟 DOM 与 Diff (Vue 2)

Vue 2 的 Diff 算法是 全量对比
当数据变化时,Vue 2 会重新生成整个组件的 VNode 树,然后和旧的 VNode 树进行对比(双端比较算法)。即使有些节点及其子节点永远不会变(静态节点),Vue 2 依然会去比对它们。


三、 Vue 3 的工作原理

Vue 3 在响应式系统和编译优化上做了彻底的重构。

1. 响应式原理 (Reactivity)

Vue 3 使用 Proxy 替代了 defineProperty。代码位于 packages/reactivity

  • 核心 APIProxy + Reflect

  • 源码逻辑

    • 不再需要 Observer 类,直接返回一个 Proxy 代理。
    • Track(依赖收集) :当读取属性时触发 track(target, key),将副作用函数(Effect)存入全局的 WeakMap
    • Trigger(派发更新) :当修改属性时触发 trigger(target, key),从 WeakMap 取出 Effect 执行。
// Vue 3 响应式简化版
new Proxy(target, {
  get(target, key, receiver) {
    track(target, key); // 收集依赖
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    const res = Reflect.set(target, key, value, receiver);
    trigger(target, key); // 触发更新
    return res;
  }
})

  • 优势

    • 懒代理(Lazy) :只有访问到深层对象时,才会将其转为 Proxy,初始化飞快。
    • 全能拦截:支持新增、删除属性,支持数组索引修改,支持 Map/Set。

2. 编译优化 (Compiler Optimization) —— 核心升级

Vue 3 的 Diff 算法不仅仅是快,而是**“更聪明”** 。它在编译阶段(Template -> Render Function) 做了大量标记,让运行时(Runtime) 跑得更快。

  • PatchFlags (动态标记) :
    在编译时,Vue 3 会分析模板,给动态节点打上“二进制标记”。
    比如:<div :class="cls">123</div>
    Vue 3 知道只有 class 是动态的,Diff 时只对比 class ,完全忽略内容。

  • Block Tree (区块树) :
    Vue 3 将模板切分成 Block,配合 PatchFlags,Diff 时直接跳过静态节点,只遍历动态节点数组。

    • Vue 2 Diff 复杂度 = 模板总体积
    • Vue 3 Diff 复杂度 = 动态节点的数量
  • Hoist Static (静态提升) :
    静态的节点(如 <p>永远不变</p>)在内存中只创建一次,后续更新直接复用,不再重复创建 VNode。


四、 Vue 2 和 Vue 3 的比较与区别

特性 Vue 2 Vue 3
响应式底层 Object.defineProperty Proxy
检测能力 无法检测属性增删、数组索引修改 完全支持
初始化性能 递归遍历所有属性(慢、内存高) 懒代理,访问时才转换(快)
代码组织 Options API (data, methods 分离) Composition API (逻辑关注点聚合)
Diff 算法 全量双端比较,静态节点也要比 静态标记 + Block Tree,只比动态节点
TypeScript 支持较弱,类型推断困难 核心由 TS 编写,TS 支持极其友好
体积 较大,难以 Tree-shaking 模块化拆分,支持 Tree-shaking,体积更小
Fragment 组件只能有一个根节点 支持多根节点 (Fragment)

五、 为什么 Vue 3 要做这些升级?

尤雨溪和团队进行 Vue 3 重构主要为了解决 Vue 2 的三个核心瓶颈:

1. 性能瓶颈 (Performance)

Vue 2 的响应式初始化是递归的,对于大数据量的表格或列表,启动非常慢。且 Diff 算法在大型复杂组件中,无谓的静态节点对比消耗了大量 CPU。
Vue 3 通过 Proxy 和编译优化(静态标记),实现了“按需响应”和“靶向更新”,性能大幅提升。

2. 代码组织与复用瓶颈 (Scalability)

在 Vue 2 的 Options API 中,一个功能的逻辑被拆分到 datamethodswatch 里。当组件变得巨大(几千行代码)时,维护代码需要在文件里上下反复横跳(Jumping)。且 Mixin 代码复用存在命名冲突和来源不清晰的问题。
Vue 3 引入 Composition API (组合式 API) ,允许开发者按“逻辑功能”组织代码,完美解决了大型项目的维护难题,Hooks 更是取代了 Mixin。

3. TypeScript 支持 (Developer Experience)

Vue 2 的源码是 JS 写的,通过 Flow 做类型检查,对 TS 的支持是后期补丁(this 指向在 TS 中很难推断)。随着前端工程化对 TS 需求的爆发,Vue 2 显得力不从心。
Vue 3 使用 TypeScript 重写,提供了原生的、极佳的类型推断体验。

总结

  • 原理层面:Vue 2 是劫持 setter/getter,Vue 3 是代理整个对象。
  • 更新机制:Vue 2 是全量树对比,Vue 3 是基于静态标记的动态节点追踪。
  • 目的:Vue 3 的升级是为了更快(性能)、更小(体积)、更易维护(组合式 API)以及更好的 TS 支持

从原理到实现:基于 Y.js 和 Tiptap 的实时在线协同编辑器全解析

引言

在现代办公和学习场景中,多人实时协同编辑变得越来越重要。想象一下,团队成员可以同时编辑同一份文档,每个人的光标和输入都实时可见,就像坐在同一个会议室里一样。这种功能在 Google Docs、Notion 等应用中已经变得司空见惯。今天,我将带你深入剖析如何基于 Y.js、WebRTC 和 Tiptap 构建一个完整的实时协同编辑器。

技术架构概览

我们的协同编辑系统主要由三部分组成:

  1. 前端编辑器 (TiptapEditor.vue) - 基于 Vue 3 和 Tiptap 的富文本编辑器
  2. 协同框架 (Y.js) - 负责文档状态同步和冲突解决
  3. 信令服务器 (signaling-server.js) - WebRTC 连接的中介服务
用户A浏览器 ↔ WebRTC ↔ 用户B浏览器
      ↑                       ↑
     Y.js ←→ 协同状态 ←→ Y.js
      ↓                       ↓
   Tiptap编辑器           Tiptap编辑器

核心原理深度解析

1. Y.js 的 CRDT 算法

Y.js 之所以能实现无冲突的实时协同,是因为它采用了 CRDT(Conflict-Free Replicated Data Types,无冲突复制数据类型) 算法。

传统方案的问题:

  • 如果两个用户同时编辑同一位置,传统方案需要通过锁机制或最后写入者胜出的策略
  • 这些方案要么影响用户体验,要么可能导致数据丢失

CRDT 的解决方案:

  • 每个操作都有唯一的标识符(时间戳 + 客户端ID)
  • 操作是 可交换、可结合、幂等
  • 无论操作以什么顺序到达,最终状态都是一致的
// 示例:Y.js 如何解决冲突
用户A: 在位置2插入"X" → 操作ID: [时间A, 客户端A]
用户B: 在位置2插入"Y" → 操作ID: [时间B, 客户端B]

// 即使两个操作同时发生,最终文档会变成"YX"或"XY"
// 具体顺序由操作ID决定,但所有客户端都会得到相同的结果

2. WebRTC 的 P2P 通信

WebRTC(Web Real-Time Communication)允许浏览器之间直接通信,无需通过中心服务器转发数据。

关键优势:

  • 低延迟:数据直接在浏览器间传输
  • 减轻服务器压力:服务器只负责建立连接(信令)
  • 去中心化:更健壮的系统架构

建立连接的三个步骤:

  1. 信令交换:通过信令服务器交换SDP和ICE候选
  2. NAT穿透:使用STUN/TURN服务器建立直接连接
  3. 数据传输:直接传输Y.js的更新数据

3. 文档模型映射

Tiptap(基于 ProseMirror)使用树状结构表示文档,而Y.js使用线性结构。这两者之间需要建立映射关系:

ProseMirror文档树:
document
├─ paragraph
│  ├─ text "Hello"
│  └─ text(bold) "World"
└─ bullet_list
   └─ list_item
      └─ paragraph "Item 1"

Y.js XML Fragment:
<document>
  <paragraph>Hello<bold>World</bold></paragraph>
  <bullet_list>
    <list_item><paragraph>Item 1</paragraph></list_item>
  </bullet_list>
</document>

实现细节剖析

1. 协同状态管理

让我们看看如何在 Vue 组件中管理协同状态:

// 用户信息管理
const userInfo = ref({
  name: `用户${Math.floor(Math.random() * 1000)}`,
  color: getRandomColor() // 每个用户有独特的颜色
})

// 在线用户列表
const onlineUsers = ref<any[]>([])

// 更新用户列表的函数
const updateOnlineUsers = () => {
  if (!provider.value || !provider.value.awareness) return
  
  const states = Array.from(provider.value.awareness.getStates().entries())
  const users: any[] = []
  
  states.forEach(([clientId, state]) => {
    if (state && state.user) {
      users.push({
        clientId,
        ...state.user,
        isCurrentUser: clientId === provider.value.awareness.clientID
      })
    }
  })
  
  onlineUsers.value = users
}

Awareness 系统是Y.js的一个关键特性:

  • 跟踪每个用户的 状态(姓名、颜色、光标位置等)
  • 实时广播状态变化
  • 处理用户加入/离开事件

2. 编辑器的双重模式

我们的编辑器支持两种模式,需要平滑切换:

// 单机模式初始化
const reinitEditorWithoutCollaboration = () => {
  editor.value = new Editor({
    extensions: [StarterKit, Bold, Italic, Heading, ...],
    content: '<h1>欢迎使用编辑器</h1>...' // 静态内容
  })
}

// 协同模式初始化
const reinitEditorWithCollaboration = () => {
  // 关键:协同模式下不设置初始内容
  editor.value = new Editor({
    extensions: [
      Collaboration.configure({ // 协同扩展必须放在最前面
        document: ydoc.value,
        field: 'prosemirror',
      }),
      StarterKit.configure({ history: false }), // 禁用内置历史
      Bold, Italic, Heading, ...
    ],
    // 不设置 content,由Y.js提供
  })
}

关键区别:

  • 协同模式使用 Collaboration 扩展,禁用 history
  • 内容从 Y.Doc 加载,而不是静态设置
  • 所有操作通过Y.js同步

3. WebRTC 连接的生命周期

const initCollaboration = () => {
  // 1. 创建Y.js文档
  ydoc.value = new Y.Doc()
  
  // 2. 创建WebRTC提供者
  provider.value = new WebrtcProvider(roomId.value, ydoc.value, {
    signaling: ['ws://localhost:1234'], // 信令服务器地址
    password: null,
  })
  
  // 3. 设置用户awareness
  provider.value.awareness.setLocalStateField('user', userInfo.value)
  
  // 4. 监听连接状态
  provider.value.on('status', (event) => {
    isConnected.value = event.status === 'connected'
  })
  
  // 5. 监听同步完成
  provider.value.on('synced', (event) => {
    console.log('文档同步完成:', event.synced)
  })
}

4. 信令服务器的实现

信令服务器虽然简单,但至关重要:

// 房间管理
const rooms = new Map() // roomId -> Set of WebSocket connections

wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    const data = JSON.parse(message.toString())
    
    if (data.type === 'subscribe') {
      // 客户端加入房间
      const topic = data.topic
      if (!rooms.has(topic)) rooms.set(topic, new Set())
      rooms.get(topic).add(ws)
    }
    else if (data.type === 'publish') {
      // 转发消息给房间内其他客户端
      const roomClients = rooms.get(data.topic)
      roomClients.forEach((client) => {
        if (client !== ws) { // 不转发给自己
          client.send(JSON.stringify(data))
        }
      })
    }
  })
})

信令服务器的作用:

  1. 房间管理:维护哪些客户端在哪个房间
  2. 消息转发:将SDP和ICE候选转发给对等方
  3. 连接建立:帮助WebRTC建立P2P连接

实时协同的工作流程

让我们通过一个具体场景来看系统如何工作:

场景:用户A和用户B协同编辑

1. 用户A打开编辑器
   ├─ 初始化Y.js文档
   ├─ 创建WebRTC提供者
   ├─ 连接信令服务器
   └─ 加入房间"room-abc123"

2. 用户B通过链接加入同一房间
   ├─ 初始化Y.js文档(相同roomId)
   ├─ WebRTC通过信令服务器发现用户A
   └─ 建立直接P2P连接

3. 用户A输入文字"Hello"
   ├─ Tiptap生成ProseMirror事务
   ├─ Collaboration扩展转换为Y.js操作
   ├─ Y.js操作通过WebRTC发送给用户B
   └─ 用户B的Y.js应用操作,更新Tiptap

4. 用户B同时输入"World"
   ├─ 同样流程反向进行
   ├─ Y.js的CRDT确保顺序一致性
   └─ 最终双方都看到"HelloWorld"

视觉反馈的实现

为了让用户感知到其他协作者的存在:

/* 其他用户的光标样式 */
.ProseMirror-y-cursor {
  border-left: 2px solid; /* 使用用户颜色 */
}

.ProseMirror-y-cursor > div {
  /* 显示用户名的标签 */
  background-color: var(--user-color);
  color: white;
  padding: 2px 6px;
  border-radius: 3px;
}
// 用户状态显示
<div v-for="user in onlineUsers" :key="user.clientId" 
     class="user-tag"
     :style="{
       backgroundColor: user.color + '20',
       borderColor: user.color,
       color: user.color
     }">
  <span class="user-avatar" :style="{ backgroundColor: user.color }"></span>
  {{ user.name }}
</div>

性能优化与注意事项

1. 延迟优化

// 批量更新,减少网络传输
provider.value.awareness.setLocalState({
  user: userInfo.value,
  cursor: editor.value.state.selection.from,
  // 其他状态...
})

// 节流频繁更新
let updateTimeout
const throttledUpdate = () => {
  clearTimeout(updateTimeout)
  updateTimeout = setTimeout(updateOnlineUsers, 100)
}

2. 错误处理与降级

try {
  // 尝试WebRTC连接
  provider.value = new WebrtcProvider(roomId.value, ydoc.value, config)
} catch (error) {
  console.error('WebRTC连接失败,降级到模拟模式:', error)
  
  // 降级策略:模拟协同,实际为单机
  isConnected.value = true
  onlineUsers.value = [{
    clientId: 1,
    ...userInfo.value,
    isCurrentUser: true
  }]
  
  // 提示用户
  showToast('协同模式不可用,已切换到单机模式')
}

3. 内存管理

// 组件卸载时清理
onBeforeUnmount(() => {
  if (editor.value) editor.value.destroy()
  if (provider.value) {
    provider.value.disconnect()
    provider.value.destroy()
  }
  if (ydoc.value) ydoc.value.destroy()
})

最终效果

在这里插入图片描述 两个用户同时编辑,各在互不影响 在这里插入图片描述

部署与扩展

1. 生产环境部署

// 生产环境信令服务器配置
const provider = new WebrtcProvider(roomId, ydoc, {
  signaling: [
    'wss://signaling1.yourdomain.com',
    'wss://signaling2.yourdomain.com' // 多节点冗余
  ],
  password: 'secure-room-password', // 房间密码保护
  maxConns: 20, // 限制最大连接数
})

2. 扩展功能

  • 离线支持:使用 IndexedDB 存储本地副本
  • 版本历史:利用 Y.js 的快照功能
  • 权限控制:不同用户的不同编辑权限
  • 插件系统:扩展编辑器功能

总结

构建实时协同编辑器是一个复杂的系统工程,涉及多个技术栈:

  1. Y.js 提供了理论基础(CRDT算法)和核心同步能力
  2. WebRTC 实现了高效的P2P数据传输
  3. Tiptap 提供了优秀的编辑器体验和扩展性
  4. Vue 3 构建了响应式的用户界面

这个项目的关键成功因素在于各个组件之间的无缝集成。Y.js处理数据一致性,WebRTC处理网络通信,Tiptap处理用户交互,而Vue将它们有机地组合在一起。

完整代码联系作者获取!

企业级RBAC 实战(八)手撸后端动态路由,拒绝前端硬编码

在企业级后台中,硬编码路由(写死在 router/index.js)是维护的噩梦。本文将深入讲解如何根据用户权限动态生成侧边栏菜单。我们将构建后端的 getRouters 递归接口,并在前端利用 Vite 的 import.meta.glob 实现组件的动态挂载,最后彻底解决路由守卫中经典的“死循环”和“刷新白屏”问题。

学习之前先浏览 前置专栏文章

一、 引言:为什么要做动态路由?

在简单的后台应用中,我们通常会在前端 router/routes.ts 中写死所有路由。但在 RBAC(基于角色的权限控制)模型下,这种做法有两个致命缺陷:

  1. 安全性低:普通用户虽然看不到菜单,但如果在浏览器地址栏手动输入 URL,依然能进入管理员页面。
  2. 维护成本高:每次新增页面都要修改前端代码并重新打包部署。

目标:前端只保留“登录”和“404”等基础页面,其他所有业务路由由后端根据当前用户的角色权限动态返回。

二、 后端实现:构建路由树 (getRouters)

后端的核心任务是:查询当前用户的菜单 -> 过滤掉隐藏的/无权限的 -> 组装成 Vue Router 需要的 JSON 树。

1. 数据结构转换

我们在上一篇设计了 sys_menus 表。Vue Router 需要的结构包含 path, component, meta 等字段。我们需要一个递归函数将扁平的数据库记录转为树形结构。

文件:routes/menu.js

// 辅助函数:将数据库扁平数据转为树形结构
function buildTree(items, parentId = 0) {
  const result = []
  for (const item of items) {
    // 兼容字符串和数字的 ID 对比
    if (item.parent_id == parentId) {
      // 组装 Vue Router 标准结构
      const route = {
        name: toCamelCase(item.path), // 自动生成驼峰 Name
        path: item.path,
        hidden: item.hidden === 1,    // 数据库 1/0 转布尔
        component: item.component,    // 此时还是字符串,如 "system/user/index"
         // 只有当 redirect 有值时才添加该字段
        ...(item.redirect && { redirect: item.redirect }),
        // 只有当 always_show 为 1 时才添加,并转为布尔
        ...(item.alwaysShow === 1 && { alwaysShow: true }),
        meta: {
          title: item.menu_name,
          icon: item.icon,
          noCache: item.no_cache === 1
        }
      }
      
      const children = buildTree(items, item.id)
      if (children.length > 0) {
        route.children = children
      }
      result.push(route)
    }
  }
  return result
}

2. 接口实现

这里有一个关键逻辑:上帝模式普通模式的区别。

  • Admin:直接查 sys_menus 全表(排除被物理删除的)。
  • 普通用户:通过 sys_users -> sys_roles -> sys_role_menus -> sys_menus 进行四表联查,只获取拥有的权限。

文件 route/menu.js

router.get('/getRouters', authMiddleware, async (req, res, next) => {
  try {
    const userId = req.user.userId
    const { isAdmin } = req.user

    let sql = ''
    let params = []

    const baseFields = `m.id, m.parent_id, m.menu_name, m.path, m.component, m.icon, m.hidden`

    if (isAdmin) {
      // 管理员:看所有非隐藏菜单
      sql = `SELECT ${baseFields} FROM sys_menus m WHERE m.hidden = 0 ORDER BY m.sort ASC`
    } else {
      // 普通用户:通过中间表关联查询
      sql = `
        SELECT ${baseFields} 
        FROM sys_menus m
        LEFT JOIN sys_role_menus rm ON m.id = rm.menu_id
        LEFT JOIN sys_users u ON u.role_id = rm.role_id
        WHERE u.id = ? AND m.hidden = 0
        ORDER BY m.sort ASC
      `
      params.push(userId)
    }

    const [rows] = await pool.query(sql, params)
    const menuTree = buildTree(rows)

    res.json({ code: 200, data: menuTree })
  } catch (err) {
    next(err)
  }
})

三、 前端实现:组件动态加载

前端拿到后端的 JSON 后,最大的难点在于:后端返回的 component 是字符串 "system/user/index",而 Vue Router 需要的是一个 Promise 组件对象 () => import(...)

在 Webpack 时代我们要用 require.context,而在 Vite 中,我们要用 import.meta.glob。

1. Store 逻辑 (store/modules/permission.ts)

import { defineStore } from 'pinia'
import { constantRoutes } from '@/router'
import { getRouters } from '@/api/menu'
import Layout from '@/layout/index.vue'

// 1. Vite 核心:一次性匹配 views 目录下所有 .vue 文件
// 结果类似: { '../../views/system/user.vue': () => import(...) }
const modules = import.meta.glob('../../views/**/*.vue')

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    routes: [],        // 完整路由(侧边栏用)
    addRoutes: [],     // 动态路由(router.addRoute用)
    sidebarRouters: [] // 侧边栏菜单
  }),
  
  actions: {
    async generateRoutes() {
      // 请求后端
      const res: any = await getRouters()
      const sdata = JSON.parse(JSON.stringify(res.data))
      
      // 转换逻辑
      const rewriteRoutes = filterAsyncRouter(sdata)
      
      this.addRoutes = rewriteRoutes
      this.sidebarRouters = constantRoutes.concat(rewriteRoutes)
      
      return rewriteRoutes
    }
  }
})

// 遍历后台传来的路由字符串,转换为组件对象
function filterAsyncRouter(asyncRouterMap: any[]) {
  return asyncRouterMap.filter(route => {
    if (route.component) {
      if (route.component === 'Layout') {
        route.component = Layout
      } else {
        // 核心:根据字符串去 modules 里找对应的 import 函数
        route.component = loadView(route.component)
      }
    }
    // ... 递归处理 children
    if (route.children && route.children.length) {
      route.children = filterAsyncRouter(route.children)
    }
    return true
  })
}

export const loadView = (view: string) => {
  let res
  for (const path in modules) {
    // 路径匹配逻辑:从 ../../views/system/user.vue 中提取 system/user
    const dir = path.split('views/')[1].split('.vue')[0]
    if (dir === view) {
      res = () => modules[path]()
    }
  }
  return res
}

四、 核心难点:路由守卫与“死循环”

在 src/permission.ts 中,我们需要拦截页面跳转,如果是第一次进入,则请求菜单并添加到路由中。

1. 经典死循环问题

很多新手会这样写判断:

// ❌ 错误写法
if (userStore.roles.length === 0) {
  // 去拉取用户信息 -> 生成路由 -> next()
}

Bug 场景:如果新建了一个没有任何角色的用户 user01,后端返回的 roles 是空数组。

  1. roles.length 为 0,进入 if。
  2. 拉取信息,发现还是空数组。
  3. next(to) 重定向,重新进入守卫。
  4. roles.length 依然为 0... 死循环,浏览器崩溃

2. 解决方案:引入 isInfoLoaded 状态位

我们在 userStore 中增加一个 isInfoLoaded 布尔值,专门标记“是否已经尝试过拉取用户信息”

文件:src/permission.ts

import router from '@/router'
import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission' // 引入新的 store
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

NProgress.configure({ showSpinner: false })

const whiteList = ['/login', '/404']

router.beforeEach(async (to, from, next) => {
  NProgress.start()
  const userStore = useUserStore()
  const permissionStore = usePermissionStore()

  if (userStore.token) {
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done()
    } else {
      // 判断当前用户是否已拉取完 user_info 信息
      // 这里我们可以简单判断:如果 userStore.userInfo.roles.length==0 动态添加的菜单长度为0,说明还没请求菜单,但似乎 这么写  如果 用户没角色  会陷入死循环
      if (!userStore.isInfoLoaded) {
        try {
          // 2. 生成动态路由 (后端请求)
          const userInfo = await userStore.getInfo()
          console.log('userInfo--', userInfo)
          if (userInfo && userInfo.data.id) {
            const accessRoutes = await permissionStore.generateRoutes()
            // // 3. 后端返回的路由
            console.log('accessRoutes', accessRoutes)
            // 4. 动态添加路由
            accessRoutes.forEach((route) => {
              router.addRoute(route)
            })
          }
          // 4. 确保路由添加完成 (Hack方法)
          next({ path: to.path, query: to.query, replace: true })
        } catch (err) {
          console.log('userinfo -err', err)
          userStore.logout()
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      } else {
        next()
      }
    }
  } else {
    if (whiteList.includes(to.path)) {
      console.log('to.path', to.path)
      next()
    } else {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

router.afterEach(() => {
  NProgress.done()
})

五、 侧边栏递归渲染

最后一步是将 sidebarRouters 渲染到左侧菜单. 这里需要注意一个细节:el-tree 或 el-menu 的父节点折叠问题
如果一个目录只有一个子菜单(例如“首页”),我们通常希望直接显示子菜单,不显示父目录。

文件 layout/components/slideBar/index.vue

<template>
  <div :class="classObj">
    <Logo v-if="showLogo" :collapse="isCollapse" />
    <el-scrollbar wrap-class="scrollbar-wrapper" :class="sideTheme">
      <el-menu
        router
        :default-active="activeMenu"
        :collapse="isCollapse"
        :background-color="
          settingsStore.sideTheme === 'dark'
            ? variables.menuBackground
            : variables.menuLightBackground
        "
        :text-color="
          settingsStore.sideTheme === 'dark'
            ? variables.menuColor
            : variables.menuLightColor
        "
        :active-text-color="theme"
        :unique-opened="true"
        :collapse-transition="false"
      >
        <template v-for="item in slidebarRouters" :key="item.path">
          <!-- 如果只有一个子菜单,直接显示子菜单 -->
          <el-menu-item
            v-if="item.children && item.children.length === 1"
            :index="item.path + '/' + item.children[0].path"
          >
            <el-icon v-if="item.children[0].meta?.icon">
              <component :is="item.children[0].meta.icon" />
            </el-icon>
            <span>{{ item.children[0].meta.title }}</span>
          </el-menu-item>

          <!-- 如果有多个子菜单,显示下拉菜单 -->
          <el-sub-menu
            v-else-if="item.children && item.children.length > 1"
            :index="item.path"
          >
            <template #title>
              <el-icon v-if="item.meta?.icon">
                <component :is="item.meta.icon" />
              </el-icon>
              <span>{{ item.meta.title }}</span>
            </template>
            <el-menu-item
              v-for="subItem in item.children"
              :key="subItem.path"
              :index="item.path + '/' + subItem.path"
            >
              <el-icon v-if="subItem.meta?.icon">
                <component :is="subItem.meta.icon" />
              </el-icon>
              <span>{{ subItem.meta.title }}</span>
            </el-menu-item>
          </el-sub-menu>

          <!-- 如果没有子菜单,直接显示当前菜单 -->
          <el-menu-item v-else :index="item.path">
            <el-icon v-if="item.meta?.icon">
              <component :is="item.meta.icon" />
            </el-icon>
            <span>{{ item.meta.title }}</span>
          </el-menu-item>
        </template>
      </el-menu>
    </el-scrollbar>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import Logo from './Logo.vue'
import { useRoute } from 'vue-router'
import variables from '@/assets/styles/var.module.scss'
const route = useRoute()

import { useSettingsStore } from '@/store/modules/settings'
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()
const sideTheme = computed(() => settingsStore.sideTheme)
const theme = computed(() => settingsStore.theme)
const showLogo = computed(() => settingsStore.showLogo)
const isCollapse = computed(() => !appStore.sidebar.opened)
const classObj = computed(() => ({
  dark: sideTheme.value === 'dark',
  light: sideTheme.value === 'light',
  'has-logo': settingsStore.showLogo,
}))

const slidebarRouters = computed(() =>
  permissionStore.sidebarRouters.filter((item) => {
    return !item.hidden
  })
)

console.log('slidebarRouters', slidebarRouters.value)

// 激活菜单
const activeMenu = computed(() => {
  const { path } = route
  return path
})
</script>

<style scoped lang="scss">
#app {
  .main-container {
    height: 100%;
    transition: margin-left 0.28s;
    margin-left: $base-sidebar-width;
    position: relative;
  }

  .sidebarHide {
    margin-left: 0 !important;
  }

  .sidebar-container {
    -webkit-transition: width 0.28s;
    transition: width 0.28s;
    width: $base-sidebar-width !important;
    background-color: $base-menu-background;
    height: 100%;
    position: fixed;
    font-size: 0px;
    top: 0;
    bottom: 0;
    left: 0;
    z-index: 1001;
    overflow: hidden;
    -webkit-box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
    box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);

    // reset element-ui css
    .horizontal-collapse-transition {
      transition:
        0s width ease-in-out,
        0s padding-left ease-in-out,
        0s padding-right ease-in-out;
    }

    .scrollbar-wrapper {
      overflow-x: hidden !important;
    }

    .el-scrollbar__bar.is-vertical {
      right: 0px;
    }

    .el-scrollbar {
      height: 100%;
    }

    &.has-logo {
      .el-scrollbar {
        height: calc(100% - 50px);
      }
    }

    .is-horizontal {
      display: none;
    }

    a {
      display: inline-block;
      width: 100%;
      overflow: hidden;
    }

    .svg-icon {
      margin-right: 16px;
    }

    .el-menu {
      border: none;
      height: 100%;
      width: 100% !important;
    }

    .el-menu-item,
    .menu-title {
      overflow: hidden !important;
      text-overflow: ellipsis !important;
      white-space: nowrap !important;
    }

    .el-menu-item .el-menu-tooltip__trigger {
      display: inline-block !important;
    }

    // menu hover
    .sub-menu-title-noDropdown,
    .el-sub-menu__title {
      &:hover {
        background-color: rgba(0, 0, 0, 0.06) !important;
      }
    }

    & .theme-dark .is-active > .el-sub-menu__title {
      color: $base-menu-color-active !important;
    }

    & .nest-menu .el-sub-menu > .el-sub-menu__title,
    & .el-sub-menu .el-menu-item {
      min-width: $base-sidebar-width !important;

      &:hover {
        background-color: rgba(0, 0, 0, 0.06) !important;
      }
    }

    & .theme-dark .nest-menu .el-sub-menu > .el-sub-menu__title,
    & .theme-dark .el-sub-menu .el-menu-item {
      background-color: $base-sub-menu-background !important;

      &:hover {
        background-color: $base-sub-menu-hover !important;
      }
    }
  }

  .hideSidebar {
    .sidebar-container {
      width: 54px !important;
    }

    .main-container {
      margin-left: 54px;
    }

    .sub-menu-title-noDropdown {
      padding: 0 !important;
      position: relative;

      .el-tooltip {
        padding: 0 !important;

        .svg-icon {
          margin-left: 20px;
        }
      }
    }

    .el-sub-menu {
      overflow: hidden;

      & > .el-sub-menu__title {
        padding: 0 !important;

        .svg-icon {
          margin-left: 20px;
        }
      }
    }

    .el-menu--collapse {
      .el-sub-menu {
        & > .el-sub-menu__title {
          & > span {
            height: 0;
            width: 0;
            overflow: hidden;
            visibility: hidden;
            display: inline-block;
          }
          & > i {
            height: 0;
            width: 0;
            overflow: hidden;
            visibility: hidden;
            display: inline-block;
          }
        }
      }
    }
  }

  .el-menu--collapse .el-menu .el-sub-menu {
    min-width: $base-sidebar-width !important;
  }

  // mobile responsive
  .mobile {
    .main-container {
      margin-left: 0px;
    }

    .sidebar-container {
      transition: transform 0.28s;
      width: $base-sidebar-width !important;
    }

    &.hideSidebar {
      .sidebar-container {
        pointer-events: none;
        transition-duration: 0.3s;
        transform: translate3d(-$base-sidebar-width, 0, 0);
      }
    }
  }

  .withoutAnimation {
    .main-container,
    .sidebar-container {
      transition: none;
    }
  }
}

// when menu collapsed
.el-menu--vertical {
  & > .el-menu {
    .svg-icon {
      margin-right: 16px;
    }
  }

  .nest-menu .el-sub-menu > .el-sub-menu__title,
  .el-menu-item {
    &:hover {
      // you can use $sub-menuHover
      background-color: rgba(0, 0, 0, 0.06) !important;
    }
  }

  // the scroll bar appears when the sub-menu is too long
  > .el-menu--popup {
    max-height: 100vh;
    overflow-y: auto;

    &::-webkit-scrollbar-track-piece {
      background: #d3dce6;
    }

    &::-webkit-scrollbar {
      width: 6px;
    }

    &::-webkit-scrollbar-thumb {
      background: #99a9bf;
      border-radius: 20px;
    }
  }
}

.dark {
  background-color: $base-menu-background !important;
}
.light {
  background-color: $base-menu-light-background !important;
}
.scrollbar-wrapper {
  overflow-x: hidden !important;
}
.has-logo {
  .el-scrollbar {
    height: calc(100% - 50px);
  }
}
</style>

六、 总结与下篇预告

通过本篇实战,我们实现了:

  1. 后端:根据角色权限过滤菜单数据。
  2. 前端:利用 Vite 的 glob 导入特性,将后端字符串动态转为前端组件。
  3. 守卫:通过 isInfoLoaded 状态位完美解决了空权限用户的死循环问题。

现在的系统已经具备了动态菜单的能力。但是,如何更方便地管理这些用户和角色呢?  如果用户很多,列表怎么分页?怎么模糊搜索?

下一篇:《企业级全栈 RBAC 实战 (九):用户管理与 SQL 复杂查询优化》,我们将深入 Element Plus 表格组件与 MySQL 分页查询的结合。

❌