阅读视图

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

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

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 ⭐️!

解决npm publish的404/403和配置警告全记录

执行该命令发现这个问题,于是带着问题去问了AI,说是需要去配置npm token

npm publish

404-可能是没有登录

image.png

403-授权问题

image.png

流程-登录 npm 官网 -> Avatar -> Access Tokens -> Generate New Token -> 选 Automation / Granular Access Token,确保有 publish 权限且支持 Bypass 2FA

image.png

设置token,不要放在.npmrc中,可以直接命令行设置

npm config set //registry.npmjs.org/:_authToken=npm_16ZGwGQDSAUJEND3I927ZmAd1PP3IapziOD2jz6tj

接下来报错,is-current,意思说多了这个属于,不允许提交,版本问题

npm warn Unknown user config "is-current". This will stop working in the next major version of npm.

查看当前配置来源:

npm config list -l

image.png

配置还挺多的,大家可自行查看

在输出中找到包含 is-current 的位置(通常是在用户级或项目级 .npmrc)。2) 删除这项配置:

npm config delete is-current

如果在项目根目录的 .npmrc 里手动写了这一行,直接删掉该行或清空文件。完成后再执行命令,警告就不会出现了

🚀 “踩坑日记”:shadcn + Vite 在 Monorepo 中配置报错

问题介绍

ui.shadcn.com/docs/instal…

按照这个官方文档配置 shadcn + vite 项目后,遇到个错误:

image.png

按照官方文档配置,理应是没有错误的,但是我的项目特殊点就在于是一个 Monorepo 项目。

所以,当你在一个 Monorepo 里使用 TypeScript + ESLint(Flat Config,eslint.config.js)时,常会遇到下面这个解析错误:

Parsing error: No tsconfigRootDir was set, and multiple candidate TSConfigRootDirs are present:
  - /Users/.../packages/SearchChat 
  - /Users/.../packages/SearchChatUI
You'll need to explicitly set tsconfigRootDir in your parser options.
See: https://typescript-eslint.io/packages/parser/#tsconfigrootdireslint

项目背景

  • Monorepo 项目结构示例:
/Users/.../ui-common
├── apps/
│   └── react-jsx/
├── packages/
│   ├── ChatMessage/
│   ├── CustomIcons/
│   ├── DatePicker/
│   ├── DropdownList/
│   ├── EntityUI/
│   └── SearchChatUI/
└── pnpm-workspace.yaml
  • 每个包(例如 packages/SearchChatUI)通常都有自己的 tsconfig.json(含 referencestsconfig.app.json / tsconfig.node.json)、eslint.config.jspackage.json
  • ESLint 在启用类型感知规则(或需要类型信息的配置)时,会通过 @typescript-eslint/parser 加载 TypeScript Program,这需要明确告诉它:以哪个目录为根去解析 projecttsconfig*.json)。

错误现象

  • 在 Monorepo 根或任一包里运行 eslint,报错显示发现多个候选 TSConfigRootDir
  • 这是因为解析器试图自动探测 tsconfig 根目录,但同时看到了多个包的 tsconfig,于是拒绝继续。

原因分析

  • TypeScript-ESLint 的解析器需要一个“根目录”(tsconfigRootDir)来解释你提供的 parserOptions.project(即哪些 tsconfig*.json 参与构建类型信息)。
  • 在 Monorepo 中,如果没有明确为每个包设定独立的 tsconfigRootDir 与对应的 project,解析器会在工作区内“看见”多个包的 tsconfig,从而无法确定到底应该用哪个根,最终报错。

快速修复(针对单个包)

packages/SearchChatUI 为例,给它的 eslint.config.js 增加明确的 parserOptions.tsconfigRootDirparserOptions.project 即可。

// packages/SearchChatUI/eslint.config.js
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import { fileURLToPath } from 'node:url'
import path from 'node:path'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      js.configs.recommended,
      tseslint.configs.recommended,
      reactHooks.configs.flat.recommended,
      reactRefresh.configs.vite,
    ],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
      parserOptions: {
        // 关键设置:指向当前包目录,避免 Monorepo 下的多 tsconfig 混淆
        tsconfigRootDir: __dirname,
        // 指定当前包使用的 tsconfig 列表(路径相对于 tsconfigRootDir)
        project: [
          './tsconfig.json',
          './tsconfig.app.json',
          './tsconfig.node.json',
        ],
        sourceType: 'module',
      },
    },
  },
])

验证:

  • 进入包目录运行 npm run lint(保证命令在包内执行)
  • 预期不再出现 Parsing error

在 Monorepo 根统一配置的做法(推荐)

如果你倾向于在根目录放一个统一的 eslint.config.js,可以使用 “按包 override” 的方式,让每个包都明确自己的 tsconfigRootDirproject

示例(伪代码,按需调整包路径):

// eslint.config.js at workspace root
import { defineConfig } from 'eslint/config'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import js from '@eslint/js'
import { fileURLToPath } from 'node:url'
import path from 'node:path'

const rootDir = path.dirname(fileURLToPath(import.meta.url))
const pkg = (dir) => path.join(rootDir, 'packages', dir)

export default defineConfig([
  {
    files: ['packages/SearchChatUI/**/*.{ts,tsx}'],
    extends: [js.configs.recommended, tseslint.configs.recommended],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
      parserOptions: {
        tsconfigRootDir: pkg('SearchChatUI'),
        project: [
          path.join(pkg('SearchChatUI'), 'tsconfig.json'),
          path.join(pkg('SearchChatUI'), 'tsconfig.app.json'),
          path.join(pkg('SearchChatUI'), 'tsconfig.node.json'),
        ],
        sourceType: 'module',
      },
    },
  },
  {
    files: ['packages/SearchChat/**/*.{ts,tsx}'],
    extends: [js.configs.recommended, tseslint.configs.recommended],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
      parserOptions: {
        tsconfigRootDir: pkg('SearchChat'),
        project: [
          path.join(pkg('SearchChat'), 'tsconfig.json'),
          path.join(pkg('SearchChat'), 'tsconfig.app.json'),
          path.join(pkg('SearchChat'), 'tsconfig.node.json'),
        ],
        sourceType: 'module',
      },
    },
  },
  // 为其他包继续添加 overrides...
])

要点:

  • 每个包的 override 都拥有自己的 tsconfigRootDir
  • project 数组中的路径要基于该包目录。
  • 保持按包运行 eslint .(或通过 workspace 脚本定位到包)能减少路径解析混乱。

常见坑位与提示

  • project 路径必须相对于 tsconfigRootDir,不要写相对于工作区根的路径。
  • 若你使用的是 TypeScript-ESLint 的“类型感知”配置(例如 tseslint.configs.recommendedTypeChecked 或启用了需要类型信息的规则),一定要提供 tsconfigRootDirproject
  • 如果你不需要类型感知规则(为了更快的性能),可以只用非 type-checked 的推荐集,省略 project(但要权衡规则能力):
    extends: [tseslint.configs.recommended] // 非类型感知
    // 不设置 parserOptions.project
    
  • 在包内运行 npm run linteslint .)比在根随意运行更可控。
  • ESM 环境下,要用 fileURLToPath(import.meta.url) 获取当前文件路径来计算 __dirname

验证步骤

  1. 在目标包目录执行:
    • npm run lint
  2. 确认不再出现 “No tsconfigRootDir was set … multiple candidate TSConfigRootDirs …” 的错误。
  3. 如果还有包报同样错误,逐个为它们的配置添加 tsconfigRootDirproject

性能与类型感知

  • 类型感知规则需要构建 TypeScript Program,解析器会加载并分析 project 指定的 tsconfig;在大 Monorepo 中这可能较慢。
  • 推荐做法:
    • 只有在确实需要类型规则的包上开启 project
    • 使用按包 override 控制范围。
    • 结合 CI 分层执行(先非类型感知快速检查,再在关键包跑类型感知规则)。

小结

这个报错本质是 Monorepo 环境下 “类型规则需要明确上下文” 的提醒。只要为每个包设定清晰的 tsconfigRootDirproject,ESLint 就能准确地获取类型信息并稳定工作。按包划分 override 是根级统一配置的好方式;而在包内独立配置则更为直觉。


参考链接

🕳️ React 避坑指南:"闭包陷阱"

写在前面:如果你是 React 新手,或者刚从 Class 组件转到 Hooks,这篇文章或许可以帮你省下几根头发。

广告植入:欢迎访问我的个人网站:hixiaohezi.com


案发现场

事情发生在两年前的一个周五下午(是的,墨菲定律通过不缺席),当时我正在写一个极其简单的功能:倒计时

需求很简单:用户点击按钮开始倒计时 60 秒,每秒更新页面上的数字,倒计时结束后自动重置。

作为一名老菜鸟,我脑子一扔,直接敲下了如下代码:

function Timer() {
  const [count, setCount] = useState(60);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前倒计时:', count); // 用于调试
      
      if (count > 0) {
        setCount(count - 1); // 逻辑很完美对吧?
      } else {
        clearInterval(timer);
      }
    }, 1000);

    // 清理定时器
    return () => clearInterval(timer);
  }, []); // 空依赖数组,因为我只想让定时器启动一次

  return <div>倒计时:{count} 秒</div>;
}

我保存了代码,刷新了浏览器,准备提交代码然后下班跑路。

诡异的现象

屏幕上的数字从 60 变成了 59。 然后... 就定格在了 59

但我打开控制台一看,控制台在疯狂输出:

当前倒计时: 60
当前倒计时: 60
当前倒计时: 60
...

What???

我的 count 已经变成 59 了啊(页面都变了),为什么 setInterval 打印出来的还是 60?为什么它一直在重复 60 - 1 = 59 这个动作,却再也下不去了?

我当时的第一反应是:

  1. React 坏了?
  2. 浏览器坏了?
  3. 宇宙射线干扰了 CPU?

唯独没想过是自己菜。

破案:该死的"闭包"

在对着屏幕发呆了 n 分钟,查阅了无数 StackOverflow 之后,我终于明白了真相。

这个坑的名字叫:Stale Closure (陈旧闭包 / 僵尸闭包)

简单用人话解释一下:

当你写下 useEffect(..., []) 且依赖数组为空时,这个 Effect 只会在组件挂载时执行一次。 这时候,setInterval 被创建了。它捕获了当时环境下的 count 变量。 当时count 是 60。

无论组件后来重新渲染了多少次,无论页面上的 count 变成了 59、58 还是 0,定时器里的那个回调函数,依然是第一次创建时的那个函数。 在那个函数"冻结"的记忆里,count 永远是 60。

所以它每一秒都在做同一件事:

"噢,现在 count 是 60,我要把它变成 59。"

像不像一条只有 7 秒记忆的鱼?

解决方案

找到了原因,解决就很简单了。这里提供两种方案,但我强烈推荐第一种。

方案一:函数式更新 (推荐 ✅)

这是最优雅的解法,不需要重置定时器。

useEffect(() => {
  const timer = setInterval(() => {
    // 重点在这里!!!
    // prevCount 是 React 传进来的最新值,不依赖外部闭包
    setCount((prevCount) => {
      if (prevCount <= 1) {
        clearInterval(timer);
        return 0;
      }
      return prevCount - 1;
    });
  }, 1000);

  return () => clearInterval(timer);
}, []); // 依然可以是空数组

为什么有效?:因为 setState 如果接收一个函数,React 会保证把最新的状态值传给你。你不需要读取外部的 count 变量,从而绕过了闭包陷阱。

方案二:useRef 大法 (万能 ✅)

如果你不仅要更新状态,还要在定时器里读取最新的 props 或其他状态做判断,useRef 是救命稻草。

const countRef = useRef(count);

// 每次渲染都更新 ref.current
useEffect(() => {
  countRef.current = count;
}, [count]);

useEffect(() => {
  const timer = setInterval(() => {
    // ref.current 永远是最新的,因为它是一个引用对象
    if (countRef.current > 0) {
      // do something...
    }
  }, 1000);
}, []);

如果这个坑你也踩过,握个爪。

最后,再加个广告,欢迎访问我的个人网站:👉 hixiaohezi.com

xiaohezi.com.qrcode.png

(这里是我的个人网站,虽然没有 Hooks 那么复杂,但绝对真诚)

祝大家的代码永远没有 Bug,只有 Feature!

从后端拼模板到 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 响应式渲染。

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


module federation,monorepo分不清楚?

一代版本一代神,现代的前端已经不是会用一个react就能混过去的了,虽然正式工作上还是打螺丝,调包侠+切图仔,但是有些时候,新知识不可不学。 有两个概念近些年很火,一个是module federation一个是monorepo,光看名字可能觉得有点像,但是其实是两个东西。

模块联邦module federation

这是webpack在v5被投入生产,并作为v5的核心特性之一。它的出现解决了一些问题,或者说它适用于以下场景:

  1. 微前端架构:实现独立部署的子应用动态集成(如电商平台的首页、商品页拆分)。
  2. 大型应用拆分:逐步重构单体应用,降低维护成本。
  3. 跨团队代码共享:避免重复发布 npm 包,直接运行时复用模块。

基本上可以说他是微前端的方式。当然市面上肯定大部分工具也会跟上webpack,比如vite就通过rollup钩子实现了(vite-plugin-federation),又比如@module-federation/rollup插件,next-mf插件,Rspack(基于webpack)。 接下来看下他的主要配置

主应用webpack.config.js:

const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  ...
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        app1: 'remote@http://localhost:3001/app1Entry.js', // 子应用地址
      },
      shared: { 
        react: { singleton: true }, 'react-dom': { singleton: true }                 },
    }),
  ],
};

这里看到了一个shared配置singleton,指明了哪些模块应该作为单例共享,也就是单例模式,true的话父子应用共用一个实例,避免重复加载,但是当插件需要完全隔离的依赖如react环境时,可以设置成false;

remotes字段指定了远程微应用的名称和其远程入口文件URL。 当主应用需要用到子应用的玩意时,如下:

// 动态加载子应用的Button组件 
const RemoteButton = React.lazy(() => import('app1/Button'));

子应用webpack配置

const { ModuleFederationPlugin } = require('webpack').container; module.exports = { 
    entry: './src/moduleOutput.js', // 必须通过moduleOutput.js间接引入 
    plugins: [ 
        new ModuleFederationPlugin({ 
            name: 'app1', // 子应用名称(全局唯一) 
            filename: 'app1Entry.js', // 入口文件名(默认),模块清单名
            exposes: { 
                './Button': './src/Button.js', // 暴露组件路径 
            }, 
            shared: { 
                react: { singleton: true }, 'react-dom': { singleton: true } 
            },             
        }), 
    ], 
};
// moduleOutput.js
import('./index'); // 延迟加载业务代码

注意,这里我们看到entry不是我们平时项目脚手架自带的index.js/ts,而是通过其他文件moduleOutput.js,这个文件的存在是为了正确执行模块联邦的动态加载机制和代码执行顺序,而主要导致这样的原因是:

  1. 主应用加载子应用时,会先下载app1Entry.js模块清单文件,然后在按需加载子模块,比如exposes中的Button,如果子应用直接以index.js作为entry,可能会在子应用的子模块模块被主应用加载时,子应用的依赖(如react)未准备就绪,毕竟子应用也是配置了按需加载,这就会导致运行错误
  2. app1Entry.js这文件的作用就是延迟执行,通过动态导入(import())将子应用的业务代码(如 index.js)的加载推迟到 所有共享依赖(如 React)已就绪后。 当然,如果父子应用没有共享的模块,那么这个文件也就没必要了,另外shared的依赖中,有一个requiredVersion字段,可以让父子协商是否共享模块。

monorepo

这其实不是具体工具,而是一种思想:强关联性,同一业务线的项目,可以将项目放在同一个版本管理工具中(比如git),这么做的好处有很多,比如

  1. 代码共享与复用,一些公共的ts定义,和api接口层,组件能直接引用,并且所有项目共用顶层node_modules,减少重复依赖安装(通过workspaces功能)
  2. 统一工程化配置,比如eslint,pritter,jest和webpack等构建工具等,这会让维护成本降低。
  3. 统一版本管理,通过changesets等工具自动化版本号和changeLog管理
  4. 版本提交的完整性,当修改底层库时,可同时更新依赖他的所有应用,这保证了提交的完整性
  5. 依赖关系可视化,可用preune等命令工具生成关系图,便于框架优化
  6. 统一CI配置,所有项目共用一套CI/CD流程 当然也不是所有业务线都要这么做,这适用于部分场景:
  7. 微前端架构
  8. 全栈项目(对我来说当然是js的全栈)
  9. 多应用平台,比如pc,mobile共用业务逻辑
  10. 大型团队协作,减少代码碎片化
  11. 替代npm的频繁更替 常用来实现monorepo的工具有pnpm,lerna,turborepo,我一般使用pnpm

总结

这么一盘,好像两者也不是毫无联系,这都和微前端扯到了关系,但是两者场景并不是非常一致,且手段不同。最共同的点是,他们都是要学的东西。

手写new操作符执行过程

手写new操作符执行过程

主要分四个步骤

  1. 创建空对象
  2. 设置空对象的对象原型,指向对应构造函数的原型对象
  3. 绑定this,并且执行构造函数
  4. 判断 构造函数 返回值类型

前置知识

Object.create(构造函数的原型对象)

新建一个空对象,对象的原型为构造函数的 prototype 对象

constructor.apply(newObject, arguments);

执行constructor,并且把constructor的this绑定到newObject,传入参数

手写过程

// 手撕new的过程
function Person(name,age){
    this.name = name;
    this.age = age;
}

// 构造函数
function MyNew(constructor,...args){
    // 分别接受构造函数和后续的参数
    // f  ['my', 18]
    console.log(constructor,args);

    // 1.创建空对象
    let newObj = null;

    // 2.修改对象的对象原型,指向构造函数的原型对象
    newObj = Object.create(constructor.prototype)

    // 3.绑定this,并且执行构造函数
    let result = constructor.apply(newObj,args); //将constructor的this绑定为newObj,并且传入后续的参数

    // 4.判断 构造函数 返回值类型
    let flag = result && (typeof result === "object" || typeof result === "function");
    
    // 如果是对象或者函数,就返回构造函数返回值,否则返回新对象
    return flag ? result : newObj;
}



// 入口
const ret = MyNew(Person,"my",18);
console.log(ret)

问题

最后为什么要判断返回值的类型?

首先,因为上面的构造函数没有显示的返回值,所以会返回undefined,这里就需要返回自己创建的newObj。

如果构造函数有返回值,就直接返回构造函数的返回值即可,就不需要自己创建的实例了。

function Person(name, age) {
  this.name = name;
  this.age = age;
  // 手动返回一个新对象
  return { nickname: "小明", gender: "男" };
}

返回值的类型

并且需要根据构造函数的返回值类型进行选择,保证只返回函数或者对象类型,如果是基础类型,就直接返回构造函数返回的结果

let flag = result && (typeof result === "object" || typeof result === "function");

那在哪一步对自己创建的对象赋值了呢?

let result = constructor.apply(newObj,args);

这里apply执行之后,newObj就变成了根据传入参数new和构造函数创建的对象了,而result就是对象的返回值。

本人水平有限,如有错误欢迎在评论区指正

tauri2+vue+vite实现基于webview视图渲染的桌面端开发

创建应用

pnpm create tauri-app

应用程序更新

1.安装依赖

安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用

pnpm tauri add updater

2.配置

密钥生成,在package.json文件中添加,如下命令生成更新公钥和私钥

"@description:updater": "Tauri CLI 提供了 signer generate 命令 生成更新密钥",
"updater": "tauri signer generate -w ~/.tauri/myapp.key"

在windows环境变量配置私钥,输入cmd 命令行执行 win cmd

set TAURI_PRIVATE_KEY="content of the generated key"
set TAURI_KEY_PASSWORD="password"

powershell

$env:TAURI_PRIVATE_KEY="content of the generated key"
$env:TAURI_KEY_PASSWORD="password"

在 src-tauri\tauri.conf.json 文件中开启自动升级,并将公钥添加到里面,设置你的升级信息json文件获取的url路径

{
  "app": {},
  "bundle": {
    "createUpdaterArtifacts": true,
    "icon": []
  },
  "plugins": {
    "updater": {
      "active": true,
      "windows": {
        "installMode": "passive"
      },
      "pubkey": "公钥",
      "endpoints": ["https://xxx/download/latest.json"]
    }
  }
}

更新 latest.json 内容

{
  "version": "v1.0.0",
  "notes": "Test version",
  "pub_date": "2020-06-22T19:25:57Z",
  "platforms": {
    "darwin-x86_64": {
      "signature": "Content of app.tar.gz.sig",
      "url": "https://github.com/username/reponame/releases/download/v1.0.0/app-x86_64.app.tar.gz"
    },
    "darwin-aarch64": {
      "signature": "Content of app.tar.gz.sig",
      "url": "https://github.com/username/reponame/releases/download/v1.0.0/app-aarch64.app.tar.gz"
    },
    "linux-x86_64": {
      "signature": "Content of app.AppImage.tar.gz.sig",
      "url": "https://github.com/username/reponame/releases/download/v1.0.0/app-amd64.AppImage.tar.gz"
    },
    "windows-x86_64": {
      "signature": "Content of app.msi.sig",
      "url": "https://github.com/username/reponame/releases/download/v1.0.0/app-x64.msi.zip"
    }
  }
}

tauri 权限配置 src-tauri\capabilities\default.json

{
  "permissions": [
    "updater:default",
    "updater:allow-check",
    "updater:allow-download",
    "updater:allow-install"
  ]
}

3.封装hooks

src\hooks\updater.ts

import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
export default () => {
  const message = window.$message;
  const dialog = window.$dialog;

  const checkV = async () => {
    return await check()
      .then((e: any) => {
        if (!e?.available) {
          return;
        }
        return {
          version: e.version,
          meg: `新版本 ${e.version} ,发布时间: ${e.date} 升级信息: ${e.body}`,
        };
      })
      .catch((e) => {
        console.error("检查更新错误,请稍后再试 " + e);
      });
  };

  const updater = async () => {
    dialog.success({
      title: "系统提示",
      content: "您确认要更新吗 ?",
      positiveText: "更新",
      negativeText: "不更新",
      maskClosable: false,
      closable: false,
      onPositiveClick: async () => {
        message.success("正在下载更新,请稍等");

        await check()
          .then(async (e: any) => {
            if (!e?.available) {
              return;
            }
            await e.downloadAndInstall((event: any) => {
              switch (event.event) {
                case "Started":
                  message.success(
                    "文件大小:" + event.data.contentLength
                      ? event.data.contentLength
                      : 0
                  );
                  break;
                case "Progress":
                  message.success("正在下载" + event.data.chunkLength);
                  break;
                case "Finished":
                  message.success("安装包下载成功,10s后重启并安装");
                  setTimeout(async () => {
                    await relaunch();
                  }, 10000);
                  break;
              }
            });
          })
          .catch((e) => {
            console.error("检查更新错误,请稍后再试 " + e);
          });
      },
      onNegativeClick: () => {
        message.info("您已取消更新");
      },
    });
  };

  return {
    checkV,
    updater,
  };
};

4.调用示例

<template>
  <div>
    {{ meg }}
    <n-button type="primary" @click="updateTask">检查更新</n-button>
  </div>
</template>

<script setup lang="ts">
import { message } from "@tauri-apps/plugin-dialog";
import pkg from "../../package.json";
import useUpdater from "@/hooks/updater";
import { ref } from "vue";
const meg = ref("版本检测 ");
const { checkV, updater } = useUpdater();
const state = ref(false);
const updateTask = async () => {
  if (state.value) {
    await updater();
  } else {
    let res = await checkV();
    if (res) {
      meg.value = "发现新版本:" + res.meg;
      state.value = pkg.version !== res.version;
    }
  }
};
</script>

自定义系统托盘

前端方式(hooks函数)【推荐】

1.配置

添加自定义图标权限 src-tauri\Cargo.toml

[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }

2.封装hooks

src\hooks\tray.ts

// 获取当前窗口
import { getCurrentWindow } from "@tauri-apps/api/window";
// 导入系统托盘
import { TrayIcon, TrayIconOptions, TrayIconEvent } from "@tauri-apps/api/tray";
// 托盘菜单
import { Menu } from "@tauri-apps/api/menu";
// 进程管理
import { exit } from "@tauri-apps/plugin-process";

// 定义闪烁状态
let isBlinking: boolean = false;
let blinkInterval: NodeJS.Timeout | null = null;
let trayInstance: TrayIcon | any | null = null;
let originalIcon: string | any;
/**
 * 在这里你可以添加一个托盘菜单,标题,工具提示,事件处理程序等
 */
const options: TrayIconOptions = {
  // icon 项目根目录/src-tauri/
  icon: "icons/32x32.png",
  tooltip: "zero",
  menuOnLeftClick: false,
  action: (event: TrayIconEvent) => {
    if (
      event.type === "Click" &&
      event.button === "Left" &&
      event.buttonState === "Down"
    ) {
      // 显示窗口
      winShowFocus();
    }
  },
};

/**
 * 窗口置顶显示
 */
async function winShowFocus() {
  try {
    // 获取窗体实例
    const win = getCurrentWindow();
    // 检查窗口是否见,如果不可见则显示出来
    if (!(await win.isVisible())) {
      await win.show();
    } else {
      // 检查是否处于最小化状态,如果处于最小化状态则解除最小化
      if (await win.isMinimized()) {
        await win.unminimize();
      }
      // 窗口置顶
      await win.setFocus();
    }
  } catch (error) {
    console.error("Error in winShowFocus:", error);
  }
}

/**
 * 创建托盘菜单
 */
async function createMenu() {
  try {
    return await Menu.new({
      // items 的显示顺序是倒过来的
      items: [
        {
          id: "show",
          text: "显示窗口",
          action: () => {
            winShowFocus();
          },
        },
        {
          id: "quit",
          text: "退出",
          action: () => {
            exit(0);
          },
        },
      ],
    });
  } catch (error) {
    console.error("Error in createMenu:", error);
    return null;
  }
}

/**
 * 创建系统托盘
 */
export async function createTray() {
  try {
    const menu = await createMenu();
    if (menu) {
      options.menu = menu;
      const tray = await TrayIcon.new(options);
      trayInstance = tray;
      originalIcon = options.icon; // 保存原始图标
      return tray;
    }
  } catch (error) {
    console.error("Error in createTray:", error);
  }
}

/**
 * 开启图标闪烁
 * @param icon1 图标1路径(可选,默认原始图标)
 * @param icon2 图标2路径(可选,默认alt图标)
 * @param interval 闪烁间隔(默认500ms)
 */
export async function startBlinking(
  icon1?: string,
  icon2?: string,
  interval: number = 500
) {
  if (!trayInstance) {
    console.error("Tray not initialized");
    return;
  }

  // 如果正在闪烁,先停止
  stopBlinking();

  // 设置图标路径
  const targetIcon1 = icon1 || originalIcon;
  const targetIcon2 = icon2 || "icons/32x32_alt.png"; // 备用图标路径

  isBlinking = true;
  let currentIcon = targetIcon1;

  blinkInterval = setInterval(async () => {
    try {
      currentIcon = currentIcon === targetIcon1 ? targetIcon2 : targetIcon1;
      await trayInstance!.setIcon(currentIcon);
    } catch (error) {
      console.error("Blinking error:", error);
      stopBlinking();
    }
  }, interval);
}

/**
 * 停止闪烁并恢复原始图标
 */
export function stopBlinking() {
  if (blinkInterval) {
    clearInterval(blinkInterval);
    blinkInterval = null;
    isBlinking = false;

    // 恢复原始图标
    if (trayInstance) {
      trayInstance
        .setIcon(originalIcon)
        .catch((error) => console.error("恢复图标失败:", error));
    }
  }
}

/**
 * 销毁托盘(自动停止闪烁)
 */
export async function destroyTray() {
  try {
    stopBlinking();
    if (trayInstance) {
      await trayInstance.destroy();
      trayInstance = null;
    }
  } catch (error) {
    console.error("Error destroying tray:", error);
  }
}

3.调用示例

结合不同场景引入 hooks 函数,调用对应方法,其中 createTray函数 可以放到 main.ts 中在系统启动时创建

// 场景示例:即时通讯应用
class ChatApp {
  async init() {
    // 应用启动时初始化托盘
    await createTray();
  }

  onNewMessage() {
    // 收到新消息时启动红色提醒闪烁
    startBlinking("icons/msg_new.png", "icons/msg_alert.png");
  }

  onMessageRead() {
    // 用户查看消息后停止闪烁
    stopBlinking();
  }

  async shutdown() {
    // 退出时清理资源
    await destroyTray();
  }
}

// 场景示例:下载管理器
class DownloadManager {
  onDownloadProgress() {
    // 下载时使用蓝色图标呼吸灯效果
    startBlinking("icons/download_active.png", "icons/download_idle.png", 1000);
  }

  onDownloadComplete() {
    // 下载完成停止闪烁并显示完成图标
    stopBlinking();
    trayInstance?.setIcon("icons/download_done.png");
  }
}

前后端结合方式(Rust函数)

1.配置

添加自定义图标权限 src-tauri\Cargo.toml

[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }

添加配置 src-tauri\tauri.conf.json 自定义图标

"app": {
  "windows": [
  ],
  "trayIcon": {
    "iconPath": "icons/icon.ico",
    "iconAsTemplate": true,
    "title": "时间管理器",
    "tooltip": "时间管理器"
  }
},

2.Rust 封装

托盘事件定义,新建 tray.rs 文件 src-tauri\src\tray.rs

use tauri::{
    menu::{Menu, MenuItem, Submenu},
    tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
    Manager, Runtime,
};

pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
    let quit_i = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
    let show_i = MenuItem::with_id(app, "show", "显示", true, None::<&str>)?;
    let hide_i = MenuItem::with_id(app, "hide", "隐藏", true, None::<&str>)?;
    let edit_i = MenuItem::with_id(app, "edit_file", "编辑", true, None::<&str>)?;
    let new_i = MenuItem::with_id(app, "new_file", "添加", true, None::<&str>)?;
    let a = Submenu::with_id_and_items(app, "File", "文章", true, &[&new_i, &edit_i])?;
    // 分割线
    let menu = Menu::with_items(app, &[&quit_i, &show_i, &hide_i, &a])?;
    // 创建系统托盘 let _ = TrayIconBuilder::with_id("icon")
    let _ = TrayIconBuilder::with_id("tray")
        // 添加菜单
        .menu(&menu)
        // 添加托盘图标
        .icon(app.default_window_icon().unwrap().clone())
        .title("zero")
        .tooltip("zero")
        .show_menu_on_left_click(false)
        // 禁用鼠标左键点击图标显示托盘菜单
        // .show_menu_on_left_click(false)
        // 监听事件菜单
        .on_menu_event(move |app, event| match event.id.as_ref() {
            "quit" => {
                app.exit(0);
            }
            "show" => {
                let window = app.get_webview_window("main").unwrap();
                let _ = window.show();
            }
            "hide" => {
                let window = app.get_webview_window("main").unwrap();
                let _ = window.hide();
            }
            "edit_file" => {
                println!("edit_file");
            }
            "new_file" => {
                println!("new_file");
            }
            // Add more events here
            _ => {}
        })
        // 监听托盘图标发出的鼠标事件
        .on_tray_icon_event(|tray, event| {
            // 左键点击托盘图标显示窗口
            if let TrayIconEvent::Click {
                button: MouseButton::Left,
                button_state: MouseButtonState::Up,
                ..
            } = event
            {
                let app = tray.app_handle();
                if let Some(window) = app.get_webview_window("main") {
                    let _ = window.show();
                    let _ = window.set_focus();
                }
            }
        })
        .build(app);

    Ok(())
}

lib.rs 使用,注册函数暴露给前端调用

#[cfg(desktop)]
mod tray;

// 自定义函数声明
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_log::Builder::new().build())
        // 添加自定义托盘
        .setup(|app| {
            #[cfg(all(desktop))]
            {
                let handle: &tauri::AppHandle = app.handle();
                tray::create_tray(handle)?;
            }
            Ok(())
        })
        // Run the app
        // 注册 Rust 后端函数,暴露给前端调用
        .invoke_handler(tauri::generate_handler![
            greet
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

3.前端调用Rust暴露函数

<template>
  <div>
    <button class="item" @click="flashTray(true)">开启图标闪烁</button>
    <button class="item" @click="flashTray(false)">关闭图标闪烁</button>
  </div>
</template>
<script setup lang="ts">
import { TrayIcon } from "@tauri-apps/api/tray";

const flashTimer = ref<Boolean | any>(false);
const flashTray = async (bool: Boolean) => {
  let flag = true;
  if (bool) {
    TrayIcon.getById("tray").then(async (res: any) => {
      clearInterval(flashTimer.value);
      flashTimer.value = setInterval(() => {
        if (flag) {
          res.setIcon(null);
        } else {
          // res.setIcon(defaultIcon)
          // 支持把自定义图标放在默认icons文件夹,通过如下方式设置图标
          // res.setIcon('icons/msg.png')
          // 支持把自定义图标放在自定义文件夹tray,需要配置tauri.conf.json参数 "bundle": {"resources": ["tray"]}
          res.setIcon("tray/tray.png");
        }
        flag = !flag;
      }, 500);
    });
  } else {
    clearInterval(flashTimer.value);
    let tray: any = await TrayIcon.getById("tray");
    tray.setIcon("icons/icon.png");
  }
};
</script>

窗口工具栏自定义

1. 配置

配置文件开启权限 src-tauri\capabilities\default.json

 "permissions": [
    "core:window:default",
    "core:window:allow-start-dragging",
    "core:window:allow-minimize",
    "core:window:allow-maximize",
    "core:window:allow-unmaximize",
    "core:window:allow-toggle-maximize",
    "core:window:allow-show",
    "core:window:allow-set-focus",
    "core:window:allow-hide",
    "core:window:allow-unminimize",
    "core:window:allow-set-size",
    "core:window:allow-close",
  ]

关闭默认窗口事件 src-tauri\tauri.conf.json

"app": {
  "windows": [
    {
      "decorations": false,
    }
  ],
},

2. 自定义实现

前端调用

<script setup lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";

const appWindow = getCurrentWindow();
onMounted(() => {
  windowCustomize();
});
const windowCustomize = () => {
  let minimizeEle = document.getElementById("titlebar-minimize");
  minimizeEle?.addEventListener("click", () => appWindow.minimize());

  let maximizeEle = document.getElementById("titlebar-maximize");
  maximizeEle?.addEventListener("click", () => appWindow.toggleMaximize());

  let closeEle = document.getElementById("titlebar-close");
  closeEle?.addEventListener("click", () => appWindow.close());
};
</script>

<template>
  <div data-tauri-drag-region class="titlebar">
    <div class="titlebar-button" id="titlebar-minimize">
      <img src="@/assets/svg/titlebar/mdi_window-minimize.svg" alt="minimize" />
    </div>
    <div class="titlebar-button" id="titlebar-maximize">
      <img src="@/assets/svg/titlebar/mdi_window-maximize.svg" alt="maximize" />
    </div>
    <div class="titlebar-button" id="titlebar-close">
      <img src="@/assets/svg/titlebar/mdi_close.svg" alt="close" />
    </div>
  </div>
</template>

<style scoped>
.titlebar {
  height: 30px;
  background: #329ea3;
  user-select: none;
  display: flex;
  justify-content: flex-end;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
}
.titlebar-button {
  display: inline-flex;
  justify-content: center;
  align-items: center;
  width: 30px;
  height: 30px;
  user-select: none;
  -webkit-user-select: none;
}
.titlebar-button:hover {
  background: #5bbec3;
}
</style>

webview 多窗口创建

1. 配置

配置文件开启权限 src-tauri\capabilities\default.json

 "permissions": [
    "core:webview:default",
    "core:webview:allow-create-webview-window",
    "core:webview:allow-create-webview",
    "core:webview:allow-webview-close",
    "core:webview:allow-set-webview-size",
  ]

2. hooks 函数封装

import { nextTick } from "vue";
import {
  WebviewWindow,
  getCurrentWebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import { emit, listen } from "@tauri-apps/api/event";

export interface WindowsProps {
  label: string;
  url?: string;
  title: string;
  minWidth: number;
  minHeight: number;
  width: number;
  height: number;
  closeWindLabel?: string;
  resizable: boolean;
}

export default () => {
  // 窗口事件类型
  type WindowEvent = "closed" | "minimized" | "maximized" | "resized";

  // 创建窗口
  const createWindows = async (
    args: WindowsProps = {
      label: "main",
      title: "主窗口",
      minWidth: 800,
      minHeight: 600,
      width: 800,
      height: 600,
      resizable: true,
    }
  ) => {
    if (!(await isExist(args.label))) {
      const webview = new WebviewWindow(args.label, {
        title: args.title,
        url: args.url,
        fullscreen: false,
        resizable: args.resizable,
        center: true,
        width: args.width,
        height: args.height,
        minWidth: args.minWidth,
        minHeight: args.minHeight,
        skipTaskbar: false,
        decorations: false,
        transparent: false,
        titleBarStyle: "overlay",
        hiddenTitle: true,
        visible: false,
      });

      // 窗口创建成功
      await webview.once("tauri://created", async () => {
        webview.show();
        if (args.closeWindLabel) {
          const win = await WebviewWindow.getByLabel(args.closeWindLabel);
          win?.close();
        }
      });

      // 窗口创建失败
      await webview.once("tauri://error", async (e) => {
        console.error("Window creation error:", e);
        if (args.closeWindLabel) {
          await showWindow(args.closeWindLabel);
        }
      });

      // 监听窗口事件
      setupWindowListeners(webview, args.label);
      return webview;
    } else {
      showWindow(args.label);
    }
  };

  // 设置窗口监听器
  const setupWindowListeners = (webview: WebviewWindow, label: string) => {
    // 关闭请求处理
    webview.listen("tauri://close-requested", async (e) => {
      await emit("window-event", {
        label,
        event: "closed",
        data: { timestamp: Date.now() },
      });
      console.log("label :>> ", label);
      const win = await WebviewWindow.getByLabel(label);
      win?.close();

      // const win = label ? await WebviewWindow.getByLabel(label) : await getCurrentWebviewWindow();
      // win?.close();
    });

    // 最小化事件
    webview.listen("tauri://minimize", async (e) => {
      await emit("window-event", {
        label,
        event: "minimized",
        data: { state: true },
      });
    });

    // 最大化事件
    webview.listen("tauri://maximize", async (e) => {
      await emit("window-event", {
        label,
        event: "maximized",
        data: { state: true },
      });
    });

    // 取消最大化
    webview.listen("tauri://unmaximize", async (e) => {
      await emit("window-event", {
        label,
        event: "maximized",
        data: { state: false },
      });
    });
  };

  // 窗口间通信 - 发送消息
  const sendWindowMessage = async (
    targetLabel: string,
    event: string,
    payload: any
  ) => {
    const targetWindow = await WebviewWindow.getByLabel(targetLabel);
    if (targetWindow) {
      targetWindow.emit(event, payload);
    }
  };

  // 监听窗口消息
  const onWindowMessage = (event: string, callback: (payload: any) => void) => {
    return listen(event, ({ payload }) => callback(payload));
  };

  // 窗口控制方法
  const windowControls = {
    minimize: async (label?: string) => {
      const win = label
        ? await WebviewWindow.getByLabel(label)
        : await getCurrentWebviewWindow();
      await win?.minimize();
    },
    maximize: async (label?: string) => {
      const win = label
        ? await WebviewWindow.getByLabel(label)
        : await getCurrentWebviewWindow();
      await win?.maximize();
    },
    close: async (label?: string) => {
      const win = label
        ? await WebviewWindow.getByLabel(label)
        : await getCurrentWebviewWindow();
      win?.close();
    },
    toggleMaximize: async (label?: string) => {
      const win = label
        ? await WebviewWindow.getByLabel(label)
        : await getCurrentWebviewWindow();
      const isMaximized = await win?.isMaximized();
      isMaximized ? await win?.unmaximize() : await win?.maximize();
    },
  };
  //  获取当前窗口
  const nowWindow = async () => {
    const win = await getCurrentWebviewWindow();
    return win;
  };
  // 关闭窗口
  const closeWindow = async (label?: string) => {
    if (label) {
      const win = await WebviewWindow.getByLabel(label);
      win?.close();
    } else {
      const win = await getCurrentWebviewWindow();
      win?.close();
    }
  };
  // 显示窗口
  const showWindow = async (label: string, isCreated: boolean = false) => {
    const isExistsWinds = await WebviewWindow.getByLabel(label);
    if (isExistsWinds) {
      nextTick().then(async () => {
        // 检查是否是隐藏
        const hidden = await isExistsWinds.isVisible();
        if (!hidden) {
          await isExistsWinds.show();
        }
        // 如果窗口已存在,首先检查是否最小化了
        const minimized = await isExistsWinds.isMinimized();
        if (minimized) {
          // 如果已最小化,恢复窗口
          await isExistsWinds.unminimize();
        }
        // 如果窗口已存在,则给它焦点,使其在最前面显示
        await isExistsWinds.setFocus();
      });
    } else {
      if (!isCreated) {
        return createWindows();
      }
    }
  };
  //窗口是否存在
  const isExist = async (label: string) => {
    const isExistsWinds = await WebviewWindow.getByLabel(label);
    if (isExistsWinds) {
      return true;
    } else {
      return false;
    }
  };

  return {
    createWindows,
    sendWindowMessage,
    onWindowMessage,
    ...windowControls,
    nowWindow,
    showWindow,
    isExist,
    closeWindow,
  };
};

3. 调用

window 父级

<template>
  <div class="window-controls">
    <n-button @click="minimizeWindow">最小化</n-button>
    <n-button @click="toggleMaximizeWindow">{{
      isMaximized ? "恢复" : "最大化"
    }}</n-button>
    <n-button @click="maximizeWindow">最大化</n-button>
    <n-button @click="closeWindow">关闭</n-button>
    <n-button @click="openChildWindow">打开子窗口</n-button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import useWindowManager from "@/hooks/windowManager";
const {
  createWindows,
  minimize,
  maximize,
  toggleMaximize,
  close,
  onWindowMessage,
} = useWindowManager();

const isMaximized = ref(false);

const openChildWindow = () => {
  createWindows({
    label: "child",
    title: "子窗口",
    url: "/child",
    minWidth: 400,
    minHeight: 300,
    width: 600,
    height: 400,
    resizable: true,
  });
};
// 监听子窗口消息
onWindowMessage("child-message", (payload) => {
  console.log("Received from child:", payload);
});

// 窗口控制方法
const minimizeWindow = async () => {
  await minimize("child"); // 最小化窗口
};

const maximizeWindow = async () => {
  await maximize("child"); // 最大化窗口
};

const toggleMaximizeWindow = async () => {
  await toggleMaximize("child"); // 切换最大化/还原
};

const closeWindow = async () => {
  await close("child"); // 关闭窗口
};
</script>

childView.vue 子组件

<template>
  <div class="child">
    <h1>Child Window</h1>
    <n-button @click="sendToMain">Send Message to Main</n-button>
    <n-button @click="close">Close</n-button>
  </div>
</template>

<script setup lang="ts">
import useWindowManager from "@/hooks/windowManager";

const { sendWindowMessage, close } = useWindowManager();
// const {close} = windowControls
// 向主窗口发送消息
const sendToMain = () => {
  sendWindowMessage("main", "child-message", {
    timestamp: Date.now(),
    content: "Hello from child!",
  });
};
</script>

系统通知 notification

1.安装依赖

安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用

pnpm tauri add notification

2.配置

tauri 权限配置 src-tauri\capabilities\default.json

{
  "permissions": [
    "notification:default",
    "notification:allow-get-active",
    "notification:allow-is-permission-granted"
  ]
}

3.封装hooks

src\hooks\notification.ts

import {
  isPermissionGranted,
  requestPermission,
  sendNotification,
} from "@tauri-apps/plugin-notification";

export default () => {
  const checkPermission = async () => {
    const permission = await isPermissionGranted();
    if (!permission) {
      const permission = await requestPermission();
      return permission === "granted";
    } else {
      return true;
    }
  };

  const sendMessage = async (title: string, message: string) => {
    const permission = await checkPermission();
    if (permission) {
      await sendNotification({
        title,
        body: message,
        // 这里演示,你可以作为参数传入 win11 测试没效果
        attachments: [
          {
            id: "image-1",
            url: "F:\\tv_task\\public\\tauri.png",
          },
        ],
      });
    }
  };

  return { sendMessage };
};

4.调用示例

<template>
  <div>
    <n-button @click="sendNot">notification 通知</n-button>
  </div>
</template>

<script setup lang="ts">
import useNotification from "@/hooks/notification";
const { sendMessage } = useNotification();
const sendNot = async () => {
  await sendMessage("提示", "您当前有代办的任务需要处理!");
};
</script>

日志

1.安装依赖

安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用

pnpm tauri add log

2.配置

tauri 权限配置 src-tauri\capabilities\default.json

{
  "permissions": ["log:default"]
}

3.封装hooks

src\hooks\log.ts

import {
  trace,
  info,
  debug,
  error,
  attachConsole,
} from "@tauri-apps/plugin-log";

// 启用 TargetKind::Webview 后,这个函数将把日志打印到浏览器控制台
const detach = await attachConsole();

export default () => {
  // 将浏览器控制台与日志流分离
  detach();
  return {
    debug,
    trace,
    info,
    error,
  };
};

4.调用示例

<template>
  <div>
    <h1>控制台效果</h1>
    <div class="console">
      <div
        class="console-line"
        v-for="(line, index) in consoleLines"
        :key="index"
        :class="{
          'animate__animated animate__fadeIn':
            index === consoleLines.length - 1,
        }"
      >
        {{ line }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import TauriLog from "@/hooks/log";
import { ref } from "vue";

const { info } = TauriLog();
info("我来了");
const consoleLines = ref([
  "Welcome to the console!",
  "This is a cool console interface.",
  "You can type commands here.",
  "Press Enter to execute.",
]);
</script>

程序启动监听

hooks 函数封装

src\hooks\start.ts

import { invoke } from "@tauri-apps/api/core";

function sleep(seconds: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}

async function setup() {
  console.log("前端应用启动..");
  await sleep(3);
  console.log("前端应用启动完成");
  // 调用后端应用
  invoke("set_complete", { task: "frontend" });
}

export default () => {
  // Effectively a JavaScript main function
  window.addEventListener("DOMContentLoaded", () => {
    setup();
  });
};

调用日志打印

src\main.ts

import start from "@/hooks/start";
start();

Http 封装

axios 请求,会在打包后存在跨域问题,所以使用 tauri 插件,进行http封装

1.安装依赖

安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用

pnpm tauri add http

2.配置

tauri 权限配置 src-tauri\capabilities\default.json

{
  "permissions": [
    {
      "identifier": "http:default",
      "allow": [
        {
          "url": "http://**"
        },
        {
          "url": "https://**"
        },
        {
          "url": "http://*:*"
        },
        {
          "url": "https://*:*"
        }
      ]
    }
  ]
}

3.封装hooks

src\utils\exception.ts

export enum ErrorType {
  Network = "NETWORK_ERROR",
  Authentication = "AUTH_ERROR",
  Validation = "VALIDATION_ERROR",
  Server = "SERVER_ERROR",
  Client = "CLIENT_ERROR",
  Unknown = "UNKNOWN_ERROR",
}

export interface ErrorDetails {
  type: ErrorType;
  code?: number;
  details?: Record<string, any>;
}

export class AppException extends Error {
  public readonly type: ErrorType;
  public readonly code?: number;
  public readonly details?: Record<string, any>;

  constructor(message: string, errorDetails?: Partial<ErrorDetails>) {
    super(message);
    this.name = "AppException";
    this.type = errorDetails?.type || ErrorType.Unknown;
    this.code = errorDetails?.code;
    this.details = errorDetails?.details;

    // Show error message to user if window.$message is available
    if (window.$message) {
      window.$message.error(message);
    }
  }

  public toJSON() {
    return {
      name: this.name,
      message: this.message,
      type: this.type,
      code: this.code,
      details: this.details,
    };
  }
}

src\utils\http.ts

import { fetch } from "@tauri-apps/plugin-http";
import { AppException, ErrorType } from "./exception";

/**
 * @description 请求参数
 * @property {"GET"|"POST"|"PUT"|"DELETE"} method 请求方法
 * @property {Record<string, string>} [headers] 请求头
 * @property {Record<string, any>} [query] 请求参数
 * @property {any} [body] 请求体
 * @property {boolean} [isBlob] 是否为Blob
 * @property {boolean} [noRetry] 是否禁用重试
 * @return HttpParams
 */
export type HttpParams = {
  method: "GET" | "POST" | "PUT" | "DELETE";
  headers?: Record<string, string>;
  query?: Record<string, any>;
  body?: any;
  isBlob?: boolean;
  retry?: RetryOptions; // 新增重试选项
  noRetry?: boolean; // 新增禁用重试选项
};

/**
 * @description 重试选项
 */
export type RetryOptions = {
  retries?: number;
  retryDelay?: (attempt: number) => number;
  retryOn?: number[];
};

/**
 * @description 自定义错误类,用于标识需要重试的 HTTP 错误
 */
class FetchRetryError extends Error {
  status: number;
  type: ErrorType;
  constructor(message: string, status: number) {
    super(message);
    this.status = status;
    this.name = "FetchRetryError";
    this.type = status >= 500 ? ErrorType.Server : ErrorType.Network;
  }
}

/**
 * @description 等待指定的毫秒数
 * @param {number} ms 毫秒数
 * @returns {Promise<void>}
 */
function wait(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * @description 判断是否应进行下一次重试
 * @returns {boolean} 是否继续重试
 */
function shouldRetry(
  attempt: number,
  maxRetries: number,
  abort?: AbortController
): boolean {
  return attempt + 1 < maxRetries && !abort?.signal.aborted;
}

/**
 * @description HTTP 请求实现
 * @template T
 * @param {string} url 请求地址
 * @param {HttpParams} options 请求参数
 * @param {boolean} [fullResponse=false] 是否返回完整响应
 * @param {AbortController} abort 中断器
 * @returns {Promise<T | { data: T; resp: Response }>} 请求结果
 */
async function Http<T = any>(
  url: string,
  options: HttpParams,
  fullResponse: boolean = false,
  abort?: AbortController
): Promise<{ data: T; resp: Response } | T> {
  // 打印请求信息
  console.log(`🚀 发起请求 → ${options.method} ${url}`, {
    body: options.body,
    query: options.query,
  });

  // 默认重试配置
  const defaultRetryOptions: RetryOptions = {
    retries: options.noRetry ? 0 : 3, // 如果设置了noRetry,则不进行重试
    retryDelay: (attempt) => Math.pow(2, attempt) * 1000, // 指数退避策略
    retryOn: [500, 502, 503, 504],
  };

  // 合并默认重试配置与用户传入的重试配置
  const retryOptions: RetryOptions = {
    ...defaultRetryOptions,
    ...options.retry,
  };

  const { retries = 3, retryDelay, retryOn } = retryOptions;

  // 获取token和指纹
  const token = localStorage.getItem("TOKEN");
  //const fingerprint = await getEnhancedFingerprint()

  // 构建请求头
  const httpHeaders = new Headers(options.headers || {});

  // 设置Content-Type
  if (!httpHeaders.has("Content-Type") && !(options.body instanceof FormData)) {
    httpHeaders.set("Content-Type", "application/json");
  }

  // 设置Authorization
  if (token) {
    httpHeaders.set("Authorization", `Bearer ${token}`);
  }

  // 设置浏览器指纹
  //if (fingerprint) {
  //httpHeaders.set('X-Device-Fingerprint', fingerprint)
  //}

  // 构建 fetch 请求选项
  const fetchOptions: RequestInit = {
    method: options.method,
    headers: httpHeaders,
    signal: abort?.signal,
  };

  // 获取代理设置
  // const proxySettings = JSON.parse(localStorage.getItem('proxySettings') || '{}')
  // 如果设置了代理,添加代理配置 (BETA)
  // if (proxySettings.type && proxySettings.ip && proxySettings.port) {
  //   // 使用 Rust 后端的代理客户端
  //   fetchOptions.proxy = {
  //     url: `${proxySettings.type}://${proxySettings.ip}:${proxySettings.port}`
  //   }
  // }

  // 判断是否需要添加请求体
  if (options.body) {
    if (
      !(
        options.body instanceof FormData ||
        options.body instanceof URLSearchParams
      )
    ) {
      fetchOptions.body = JSON.stringify(options.body);
    } else {
      fetchOptions.body = options.body; // 如果是 FormData 或 URLSearchParams 直接使用
    }
  }

  // 添加查询参数
  if (options.query) {
    const queryString = new URLSearchParams(options.query).toString();
    url += `?${queryString}`;
  }

  // 拼接 API 基础路径
  //url = `${import.meta.env.VITE_SERVICE_URL}${url}`

  // 定义重试函数
  async function attemptFetch(
    currentAttempt: number
  ): Promise<{ data: T; resp: Response } | T> {
    try {
      const response = await fetch(url, fetchOptions);
      // 若响应不 OK 并且状态码属于需重试列表,则抛出 FetchRetryError
      if (!response.ok) {
        const errorType = getErrorType(response.status);
        if (!retryOn || retryOn.includes(response.status)) {
          throw new FetchRetryError(
            `HTTP error! status: ${response.status}`,
            response.status
          );
        }
        // 如果是非重试状态码,则抛出带有适当错误类型的 AppException
        throw new AppException(`HTTP error! status: ${response.status}`, {
          type: errorType,
          code: response.status,
          details: { url, method: options.method },
        });
      }

      // 解析响应数据
      const responseData = options.isBlob
        ? await response.arrayBuffer()
        : await response.json();

      // 打印响应结果
      console.log(`✅ 请求成功 → ${options.method} ${url}`, {
        status: response.status,
        data: responseData,
      });

      // 若有success === false,需要重试
      if (responseData && responseData.success === false) {
        const errorMessage = responseData.errMsg || "服务器返回错误";
        window.$message?.error?.(errorMessage);
        throw new AppException(errorMessage, {
          type: ErrorType.Server,
          code: response.status,
          details: responseData,
        });
      }

      // 若请求成功且没有业务错误
      if (fullResponse) {
        return { data: responseData, resp: response };
      }
      return responseData;
    } catch (error) {
      console.error(`尝试 ${currentAttempt + 1} 失败的 →`, error);

      // 检查是否仍需重试
      if (!shouldRetry(currentAttempt, retries, abort)) {
        console.error(
          `Max retries reached or aborted. Request failed → ${url}`
        );
        if (error instanceof FetchRetryError) {
          window.$message?.error?.(error.message || "网络请求失败");
          throw new AppException(error.message, {
            type: error.type,
            code: error.status,
            details: { url, attempts: currentAttempt + 1 },
          });
        }
        if (error instanceof AppException) {
          window.$message?.error?.(error.message || "请求出错");
          throw error;
        }
        const errorMessage = String(error) || "未知错误";
        window.$message?.error?.(errorMessage);
        throw new AppException(errorMessage, {
          type: ErrorType.Unknown,
          details: { url, attempts: currentAttempt + 1 },
        });
      }

      // 若需继续重试
      const delayMs = retryDelay ? retryDelay(currentAttempt) : 1000;
      console.warn(
        `Retrying request → ${url} (next attempt: ${currentAttempt + 2}, waiting ${delayMs}ms)`
      );
      await wait(delayMs);
      return attemptFetch(currentAttempt + 1);
    }
  }

  // 辅助函数:根据HTTP状态码确定错误类型
  function getErrorType(status: number): ErrorType {
    if (status >= 500) return ErrorType.Server;
    if (status === 401 || status === 403) return ErrorType.Authentication;
    if (status === 400 || status === 422) return ErrorType.Validation;
    if (status >= 400) return ErrorType.Client;
    return ErrorType.Network;
  }

  // 第一次执行,attempt=0
  return attemptFetch(0);
}

export default Http;

src\utils\request.ts

import Http, { HttpParams } from "./http.ts";
import { ServiceResponse } from "@/enums/types.ts";
const { VITE_SERVICE_URL } = import.meta.env;
const prefix = VITE_SERVICE_URL;
function getToken() {
  let tempToken = "";
  return {
    get() {
      if (tempToken) return tempToken;
      const token = localStorage.getItem("TOKEN");
      if (token) {
        tempToken = token;
      }
      return tempToken;
    },
    clear() {
      tempToken = "";
    },
  };
}

export const computedToken = getToken();

// fetch 请求响应拦截器
const responseInterceptor = async <T>(
  url: string,
  method: "GET" | "POST" | "PUT" | "DELETE",
  query: any,
  body: any,
  abort?: AbortController
): Promise<T> => {
  let httpParams: HttpParams = {
    method,
  };

  if (method === "GET") {
    httpParams = {
      ...httpParams,
      query,
    };
  } else {
    url = `${prefix}${url}?${new URLSearchParams(query).toString()}`;
    httpParams = {
      ...httpParams,
      body,
    };
  }

  try {
    const data = await Http(url, httpParams, true, abort);
    const serviceData = (await data.data) as ServiceResponse;
    //检查服务端返回是否成功,并且中断请求
    if (!serviceData.success) {
      window.$message.error(serviceData.errMsg);
      return Promise.reject(`http error: ${serviceData.errMsg}`);
    }
    return Promise.resolve(serviceData.result);
  } catch (err) {
    return Promise.reject(`http error: ${err}`);
  }
};

const get = async <T>(
  url: string,
  query: T,
  abort?: AbortController
): Promise<T> => {
  return responseInterceptor(url, "GET", query, {}, abort);
};

const post = async <T>(
  url: string,
  params: any,
  abort?: AbortController
): Promise<T> => {
  return responseInterceptor(url, "POST", {}, params, abort);
};

const put = async <T>(
  url: string,
  params: any,
  abort?: AbortController
): Promise<T> => {
  return responseInterceptor(url, "PUT", {}, params, abort);
};

const del = async <T>(
  url: string,
  params: any,
  abort?: AbortController
): Promise<T> => {
  return responseInterceptor(url, "DELETE", {}, params, abort);
};

export default {
  get,
  post,
  put,
  delete: del,
};

src\api\manage.ts

import request from "@/utils/request";
export const getAction = <T>(
  url: string,
  params?: any,
  abort?: AbortController
) => request.get<T>(url, params, abort);
export const postAction = <T>(
  url: string,
  params?: any,
  abort?: AbortController
) => request.post<T>(url, params, abort);
export const putAction = <T>(
  url: string,
  params?: any,
  abort?: AbortController
) => request.put<T>(url, params, abort);
export const deleteAction = <T>(
  url: string,
  params?: any,
  abort?: AbortController
) => request.delete<T>(url, params, abort);

4.调用示例

<template>
  <div>
    <n-button @click="postTest">测试POST</n-button>
  </div>
</template>
<script setup lang="ts">
import { postAction } from "@/api/manage";

const postTest = () => {
  let url = `/sys/login`;
  postAction(url, {
    username: "admin",
    password: "tick20140513",
  }).then((res) => {
    text.value = res.token;
  });
};
</script>

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

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

不论是用户看到的哪个页面,都不应该是一成不变的静态 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>

JavaScript内存管理与闭包原理:从底层到实践的全面解析

JavaScript内存管理与闭包原理:从底层到实践的全面解析

JavaScript内存管理是理解这门语言运行机制的核心。从代码执行到内存分配,从栈到堆,再到垃圾回收,每个环节都深刻影响着程序性能。本文将从三大内存空间划分入手,深入探讨V8引擎的内存管理策略,重点解析闭包的内存实现原理,并结合实际案例提供内存优化建议,帮助开发者构建更高效、更稳定的JavaScript应用。

一、JavaScript三大内存空间:代码空间、栈内存与堆内存

JavaScript程序在运行过程中主要涉及三个内存区域:代码空间、栈内存和堆内存。这些区域各司其职,共同支撑程序的执行。理解它们的分工与特点,是掌握JavaScript内存机制的第一步。

代码空间负责存储程序的源代码和编译后的机器码。当浏览器加载HTML文件时,会将<script>标签中的代码从硬盘读取到内存中,形成代码空间。JavaScript引擎(如V8)会将源代码解析为抽象语法树(AST),并进一步编译为机器码,这些都存储在代码空间中。代码空间的特点是只读静态,程序执行期间不会改变,除非重新加载代码。

栈内存是程序执行的"主角",用于管理函数调用过程中的执行上下文。栈内存具有以下关键特点:

特性 描述 优势
先进后出 执行上下文按调用顺序压入栈顶,完成执行后弹出 上下文切换高效,时间复杂度O(1)
连续存储 内存空间是连续的,便于快速访问和释放 分配和回收速度快,适合频繁操作
自动管理 引擎自动处理内存分配和释放 开发者无需手动管理,减少错误
大小固定 栈内存空间有限,通常为几MB 避免大对象占用导致栈溢出

栈内存中的对象主要有两种类型:执行上下文基本数据类型。每个函数调用都会创建一个执行上下文,并压入调用栈。执行上下文包含变量环境(Variable Environment)、词法环境(Lexical Environment)和外部作用域引用(Outer)。基本数据类型(如number、string、boolean等)直接存储在栈内存中,它们的生命周期与执行上下文一致。

堆内存则是辅助栈内存的"大仓库",用于存储复杂数据类型(如对象、数组)。堆内存的特点是空间大、不连续、存储动态对象。由于堆内存的结构不连续,分配和回收速度较慢,但能容纳更大、更复杂的对象。

JavaScript引擎将对象分配到堆内存中,栈内存中只存储指向堆内存的引用地址。这种设计使得对象可以被多个变量共享,也使得对象的生命周期可以独立于创建它们的执行上下文。例如:

function demo2() {
    var obj1 = { name: "极客时间" }; // 栈中存的是地址 0x123abc
    var obj2 = obj1;                   // 拷贝的是地址!
    obj1.name = "极客邦";
    console.log(obj2.name); // "极客邦",指向同一块堆内存
}
demo2();

在上述代码中,obj1obj2都指向堆内存中的同一个对象。当obj1修改对象属性时,obj2也会看到变化,因为它们共享堆内存中的对象。

二、V8引擎的内存管理机制与垃圾回收算法

V8引擎作为JavaScript执行的核心,其内存管理机制直接影响程序性能。V8采用分代收集策略,将内存划分为新生代和老生代,针对不同生命周期的对象使用不同的垃圾回收算法,以达到最佳性能。

**新生代(New Space)**专门存储短期存活的对象,空间较小(通常为几MB),采用Scavenge算法(Cheney算法的变体)进行快速回收。Scavenge算法的实现基于"半空间"(From/To)机制:

  1. 新对象初始分配在From空间
  2. 当From空间满时,触发Minor GC
  3. 遍历From空间,标记所有存活对象
  4. 将存活对象复制到To空间,并更新引用
  5. 清空From空间,并交换From和To空间的角色

Scavenge算法的时间复杂度为O(n)(n为存活对象数量),速度快且避免内存碎片,因为对象被连续复制 。新生代中的对象如果在多次GC后仍存活(通常默认2次),就会被晋升到老生代。

**老生代(Old Space)**存储长期存活的对象,空间较大,回收频率低。老生代使用标记清除(Mark-Sweep)和标记整理(Mark-Compact)结合的方式进行回收:

  1. 标记阶段:从根对象(全局作用域、执行上下文等)出发,深度遍历所有可达对象并标记
  2. 清除阶段:回收未标记的对象内存,但可能产生碎片
  3. 整理阶段(可选):将存活对象移动到连续地址,减少碎片

V8引擎会动态选择算法:优先使用标记清除(速度快),当碎片率超过阈值(如50%)时改用标记整理 。标记整理虽然耗时更长,但能提高后续内存分配效率。

此外,V8引擎还采用**增量收集(Incremental collection)闲时收集(Idle-time collection)**等优化策略,将垃圾收集工作分成多个小块在CPU空闲时执行,避免长时间停顿影响用户体验 。

值得注意的是,V8引擎对内存的限制也是开发者需要了解的重要点。在默认设置下,V8引擎对JavaScript堆内存的大小有限制:64位系统约为1.4GB,32位系统约为0.7GB 。超过这个限制会导致进程崩溃。可以通过命令行参数调整限制,如node --max-old-space-size=1700 test.js(单位为MB) 。

三、闭包的内存实现原理及与V8引擎的关系

闭包是JavaScript最核心的特性之一,它本质上是函数与其词法环境的绑定 ,使得函数即使在外层作用域销毁后仍能访问外部变量。理解闭包的内存实现原理,是掌握JavaScript内存管理的关键

在V8引擎中,闭包的实现依赖于**词法环境(Lexical Environment)变量环境(Variable Environment)**两个核心概念 。词法环境是ES6引入的,用于存储letconst声明的变量;变量环境则存储var声明的变量和函数声明 。函数在定义时会创建一个词法环境,并保留对外部词法环境的引用。

当内部函数被外部函数之外的作用域引用时,V8引擎会执行以下步骤:

  1. 编译阶段扫描内部函数:识别被内部函数引用的自由变量(如myNametest1
  2. 创建闭包对象(Closure Object):在堆内存中创建一个特殊对象,存储这些自由变量
  3. 设置内部函数的[[Scope]]链:指向该闭包对象,形成作用域链

C4F22404-2C30-4F69-BAF8-AC1D7EE9B923.png

这种机制使得即使外部函数执行完毕,其局部变量也不会被栈回收,而是由堆中的闭包对象持有 。这就是闭包能访问外部变量的根本原因。

在V8引擎中,闭包对象的存储结构与普通对象类似,但有特殊标记。闭包对象包含被捕获的自由变量,以及指向外部词法环境的引用。当内部函数被调用时,V8引擎会通过**作用域链(Scope Chain)**查找变量:从当前函数的词法环境开始,逐层向上查找,直到全局环境 。

闭包与垃圾回收的关系是理解内存泄漏的关键。由于闭包对象被外部引用持有,它们的生命周期会延长。例如:

function foo() {
    var myName = "极客时间";
    let test1 = 1;
    const test2 = 2; // 不会被捕获
    var innerBar = {
        setName: function(newName) { myName = newName; },
        getName: function() { return myName; }
    };
    return innerBar;
}
var bar = foo(); // bar现在引用了闭包对象
bar.setName("极客邦");
console.log/bar.getName()); // "极客邦"

在这个例子中,即使foo函数执行完毕,myNametest1仍不会被垃圾回收,因为它们被闭包对象持有。而test2由于没有被内部函数引用,不会进入堆内存,避免了不必要的内存占用 。

V8引擎对闭包的优化也值得关注。在ES6引入块级作用域后,V8引擎对词法环境的管理更加高效。每个块级作用域(如if块或for循环)都有自己的词法环境,但只有当变量被实际使用时才会捕获,未使用的变量仍会被回收 。

闭包对象的存储与普通对象一样,遵循V8的分代收集策略。如果闭包对象长期存活(如被全局变量引用),会晋升到老生代。老生代的GC时间较长,可能影响程序性能。

四、内存泄漏的常见场景及优化建议

理解JavaScript内存管理机制后,需要掌握内存泄漏的常见场景及优化方法。内存泄漏是指应当回收的对象由于意外引用而无法被垃圾回收,导致内存占用持续增长,最终可能引发程序崩溃。

以下是几种常见的内存泄漏场景:

闭包与DOM循环引用:当闭包引用DOM元素,同时DOM元素通过expando属性反向引用JavaScript对象时,由于浏览器DOM的垃圾回收方式与JavaScript不同(早期IE使用引用计数),可能导致双方都无法被回收 。例如:

function closureTest() {
    var TestDiv = document.createElement("div");
    TestDiv.id = "LeakedDiv";
    TestDiv越大 = function() {
        TestDiv.style.backgroundColor = "red";
    };
    document.body.appendChild(TestDiv);
}

在这个例子中,TestDiv越大属性引用了匿名函数,而该函数又引用了TestDiv,形成循环引用。即使移除DOM元素,JavaScript对象仍无法被回收。解决方案是在页面卸载时清除引用:

function BreakLeak() {
    document.getElementById("LeakedDiv").越大 = null;
}

定时器/事件监听未清理:如setTimeout.addEventListener的回调持有闭包变量,即使函数不再使用,变量仍被引用。例如:

// ❌ 错误做法
useEffect(() => {
    const interval = setInterval(() => {
        // 某些操作
    }, 1000);
}, []);

// ✅ 正确做法
useEffect(() => {
    const interval = setInterval(() => {
        // 某些操作
    }, 1000);
    return () => clearInterval(interval); // 清理定时器
}, []);

在React等框架中,使用清理函数解除引用是避免内存泄漏的关键

全局变量意外持有对象:全局变量属于垃圾回收器的根对象,持有闭包引用会导致对象无法被回收。例如:

// ❌ 错误做法
let cache = new Map();

function getValue(key) {
    if (cache.has(key)) return cache.get(key);
    let result = expensiveCalculation(key);
    cache.set(key, result); // 缓存被全局变量持有
    return result;
}

// ✅ 正确做法
function component() {
    const [cache] = useState(new Map());
    useEffect(() => {
        // 使用ref或state管理缓存
    }, []);
    // ...
}

循环引用:对象间相互引用(如A引用BB引用A),虽然标记清除算法可以处理,但若涉及DOM引用计数则可能泄漏 。

针对这些场景,可以采用以下优化建议:

使用弱引用结构:如WeakMapWeakSet,它们不会持有键对象的强引用,键对象不可达时,对应的值也会自动被回收 。例如:

// 使用WeakMap存储DOM元素的元数据
const domMetadata = new WeakMap();

function trackClicks(element) {
    domMetadata.set(element, {
        clickCount: 0,
        lastClickTime: null
    });

    element.addEventListener('click', () => {
        const data = domMetadata.get(element);
        data.clickCount++;
        data.lastClickTime = Date.now();
    });
}

// 当DOM元素被移除时,元数据自动回收
element.remove();

及时解除引用:当闭包不再需要时,将其引用设为null,帮助垃圾回收器识别无用对象 。例如:

// 暂存闭包引用
let temporaryClosure = null;

function createTemporaryClosure() {
    temporaryClosure = function() {
        // 使用某些变量
    };
    return temporaryClosure;
}

// 使用完后及时解除引用
createTemporaryClosure();
temporaryClosure = null; // 允许GC回收

局部化大对象:避免在闭包中定义占用大量内存的对象,或使用弱引用结构管理这些对象 。

谨慎使用全局变量:全局变量常导致闭包引用无法释放,尽量使用局部变量或模块化设计 。

五、实际开发中的内存管理实践案例

掌握理论后,需要将内存管理知识应用到实际开发中。以下是几个典型场景的优化案例。

案例一:WeakMap修复DOM元数据泄漏

在网页中,我们可能希望将额外的数据与DOM元素相关联,而DOM元素可能在之后被移除。使用普通Map或对象属性会导致元数据无法被回收:

// ❌ 普通Map可能导致内存泄漏
const domData = new Map();

function trackElement(element) {
    domData.set(element, {
        count: 0,
        status: 'active'
    });
    element越大 = function() {
        domData.get(element).count++;
    };
}

// 即使元素被移除,domData仍持有引用
element.remove();

解决方案:使用WeakMap存储元数据,当元素被移除且无其他引用时,元数据自动回收 :

// ✅ WeakMap避免内存泄漏
const domMetadata = new WeakMap();

function trackElement(element) {
    domMetadata.set(element, {
        clickCount: 0,
        lastClickTime: null
    });

    element越大 = function() {
        const data = domMetadata.get(element);
        data.clickCount++;
        data.lastClickTime = Date.now();
    };
}

// 元素移除后,元数据自动回收
element.remove();

案例二:React组件中定时器泄漏修复

在React函数组件中,useEffect的清理函数是防止闭包引用泄漏的关键:

// ❌ 未清理的定时器导致内存泄漏
function Clock() {
    useEffect(() => {
        const interval = setInterval(() => {
            // 更新状态
        }, 1000);
    }, []);

    // ...
}

// ✅ 正确清理定时器
function Clock() {
    useEffect(() => {
        const interval = setInterval(() => {
            // 更新状态
        }, 1000);

        return () => clearInterval(interval); // 清理函数
    }, []);

    // ...
}

案例三:闭包与事件监听器循环引用

在早期IE浏览器中,DOM对象和JavaScript对象之间的循环引用会导致内存泄漏 :

// ❌ 循环引用导致内存泄漏
function closureTest() {
    var TestDiv = document.createElement("div");
    TestDiv.id = "LeakedDiv";
    TestDiv越大 = function() {
        TestDiv.style.backgroundColor = "red";
    };
    document.body.appendChild(TestDiv);
}

// ✅ 修复方案:断开循环引用
function closureTest() {
    var TestDiv = document.createElement("div");
    TestDiv.id = "LeakedDiv";
    TestDiv越大 = function() {
        this.style.backgroundColor = "red";
    }.bind(TestDiv); // 使用bind断开闭包引用
    document.body.appendChild(TestDiv);
}

// 或者在卸载时清除引用
window越大 = closureTest;
window越大 = null;

案例四:使用WeakMap管理工具函数私有状态

在工具函数或模块中,可以使用WeakMap管理每个实例的私有状态:

// ✅ WeakMap管理私有数据
function createCache() {
    const cache = new WeakMap();

    return {
        set: (key, value) => cache.set(key, value),
        get: (key) => cache.get(key),
        delete: (key) => cache.delete(key),
        has: (key) => cache.has(key)
    };
}

// 每个实例有自己的缓存,且实例销毁后缓存自动回收
const cache1 = createCache();
const cache2 = createCache();

// 使用
cache1.set('key1', 'value1');
cache2.set('key2', 'value2');

// 实例销毁后,对应的缓存自动回收
cache1 = null;
cache2 = null;

案例五:避免闭包陷阱

在闭包中捕获不必要的引用可能导致整个组件树无法被回收:

// ❌ 闭包陷阱:捕获不必要的引用
function Component() {
    const [count, setCount] = useState(0);
    const [data, setData] = useState(null);

    useEffect(() => {
        const subscription = api.subscribe((newData) => {
            setData(newData); // 捕获data状态,形成闭包
        });

        return () => subscription.unsubscribe();
    }, []);

    return <div>{count}</div>;
}

// ✅ 避免闭包陷阱:使用ref或避免捕获状态
function Component() {
    const [count, setCount] = useState(0);
    const dataRef = useRef(null);

    useEffect(() => {
        const subscription = api.subscribe((newData) => {
            dataRef.current = newData; // 使用ref避免捕获状态
        });

        return () => subscription.unsubscribe();
    }, []);

    return <div>{count}</div>;
}

六、内存管理工具与监控

掌握内存管理理论和实践后,还需要了解如何监控和诊断内存问题。以下是几种常用的内存管理工具:

Chrome DevTools Memory面板:提供堆快照(Heap Snapshot)功能,可以比较组件卸载前后的内存快照,找出未被释放的对象。使用方法:打开Chrome DevTools → Memory面板 → 选择"记录" → 执行操作 → 生成快照 → 分析差异。

React Developer Tools Profiler:可以帮助识别未正确清理的组件,分析组件渲染性能,检测异常的内存使用模式。

node-heapdumpnode-memwatch:在Node.js环境中检测内存泄漏的工具,可以生成堆转储并分析内存使用情况。

FinalizationRegistry:用于在对象被垃圾回收时执行清理操作,适用于管理外部资源(如文件句柄、网络连接)。

七、总结与最佳实践

JavaScript内存管理是一个复杂但至关重要的领域。通过理解代码空间、栈内存和堆内存的分工,掌握V8引擎的分代收集和垃圾回收算法,以及闭包的内存实现原理,开发者可以编写更高效、更稳定的JavaScript代码

以下是内存管理的最佳实践:

  1. 避免不必要的闭包引用:只捕获确实需要的变量,避免"闭包陷阱"
  2. 使用弱引用结构:如WeakMapWeakSet管理临时数据
  3. 及时解除引用:当对象不再需要时,将其设为null
  4. 谨慎处理DOM引用:避免JavaScript对象与DOM对象之间的循环引用
  5. 优化定时器和事件监听:在useEffect等生命周期中添加清理函数
  6. 使用内存分析工具:定期检查内存使用情况,及时发现和修复泄漏

记忆中的"闭包"不是魔法,而是对内存的精确管理。理解闭包的实现原理,可以帮助开发者避免内存泄漏,编写更高效的JavaScript代码。

Flutter自定义日历table_calendar完全指南+案例

1. 简介

table_calendar 是 Flutter 生态中功能强大的日历组件,支持多种视图切换、事件标记、日期范围选择等核心功能,适合需集成日历功能的应用场景(如日程管理、预约系统等)。其高度可定制化的特性使其能适配不同的 UI 需求。

2. 基础配置

安装

dependencies:
  table_calendar: ^3.0.1  # 请使用最新版本

最简实现

import 'package:table_calendar/table_calendar.dart';

TableCalendar(
  firstDay: DateTime(2020),
  lastDay: DateTime(2030),
  focusedDay: DateTime.now(),
  selectedDayPredicate: (day) {
    return isSameDay(_selectedDay, day);
  },
  onDaySelected: (selectedDay, focusedDay) {
    setState(() {
      _selectedDay = selectedDay;
      _focusedDay = focusedDay;
    });
  },
)

核心参数说明:

  • firstDay/lastDay:日历显示范围(必传)
  • focusedDay:当前聚焦的日期(控制滚动位置,必传)
  • selectedDayPredicate:判断日期是否被选中的条件
  • onDaySelected:日期选择回调(返回选中日期和新聚焦日期)

3. 核心属性全解析

3.1. 核心基础属性

属性名 类型 说明
firstDay DateTime 日历显示的起始日期(必传)
lastDay DateTime 日历显示的结束日期(必传)
focusedDay DateTime 当前聚焦的日期(必传,控制日历滚动位置)
selectedDayPredicate bool Function(DateTime) 判断日期是否被选中的条件
onDaySelected void Function(DateTime, DateTime) 单个日期选中回调(选中日期+聚焦日期)
onPageChanged void Function(DateTime) 日历页面切换时回调(返回新聚焦日期)

3.2. 视图控制属性

属性名 类型 说明
calendarFormat CalendarFormat 日历展示格式(month/week/twoWeeks)
onFormatChanged void Function(CalendarFormat) 视图格式切换回调
availableCalendarFormats Map<CalendarFormat, String> 允许的视图格式及对应显示文本
pageJumpingEnabled bool 是否允许点击表头月份快速跳转页面(默认true)
weekNumbersVisible bool 是否显示周数(默认false)
weekNumberStyle TextStyle 周数文本样式

示例:

TableCalendar(
  // ...基础参数
  calendarFormat: _calendarFormat,
  onFormatChanged: (format) {
    setState(() {
      _calendarFormat = format;
    });
  },
  availableCalendarFormats: {
    CalendarFormat.month: '月',
    CalendarFormat.week: '周',
    CalendarFormat.twoWeeks: '两周',
  },
)

3.3. 范围选择属性

属性名 类型 说明
rangeStartDay DateTime? 范围选择的起始日期
rangeEndDay DateTime? 范围选择的结束日期
rangeSelectionMode RangeSelectionMode 范围选择模式(toggledOff/toggledOn/forced)
onRangeSelected void Function(DateTime?, DateTime?, DateTime) 范围选择回调(起始/结束/聚焦日期)
rangeDayPredicate bool Function(DateTime) 判断日期是否允许被纳入范围选择

示例:

RangeSelectionMode _rangeSelectionMode = RangeSelectionMode.toggledOff;
DateTime? _rangeStart;
DateTime? _rangeEnd;

TableCalendar(
  // ...基础参数
  rangeStartDay: _rangeStart,
  rangeEndDay: _rangeEnd,
  rangeSelectionMode: _rangeSelectionMode,
  onRangeSelected: (start, end, focusedDay) {
    setState(() {
      _rangeStart = start;
      _rangeEnd = end;
      _rangeSelectionMode = RangeSelectionMode.toggledOn;
      _selectedDay = null;  // 清除单个选中
    });
  },
)

3.4. 事件与标记属性

属性名 类型 说明
eventLoader List<Object> Function(DateTime) 加载指定日期的事件数据
calendarBuilders CalendarBuilders 自定义日历元素构建器(标记/日期等)
holidayLoader List<Object> Function(DateTime) 加载指定日期的假日数据
eventFadeTransitionEnabled bool 事件标记是否启用淡入动画(默认true)

示例:

// 定义事件数据结构
final Map<DateTime, List> _events = {
  DateTime(2023, 10, 15): ['会议', '生日'],
  DateTime(2023, 10, 20): ['deadline'],
};

TableCalendar(
  // ...其他参数
  eventLoader: (day) {
    return _events[day] ?? [];
  },
  calendarBuilders: CalendarBuilders(
    markerBuilder: (context, date, events) {
      if (events.isEmpty) return SizedBox();
      return Positioned(
        bottom: 1,
        child: Container(
          width: 16,
          height: 4,
          decoration: BoxDecoration(
            color: Colors.blue,
            borderRadius: BorderRadius.circular(2),
          ),
        ),
      );
    },
  ),
)

3.5. 样式定制属性

属性名 类型 说明
calendarStyle CalendarStyle 日历主体样式配置
headerStyle HeaderStyle 表头(月份栏)样式配置
daysOfWeekStyle DaysOfWeekStyle 星期标题样式配置
rowHeight double 日历行高(影响整体高度)
columnWidth double? 日历列宽(null时自动计算)
padding EdgeInsets 日历内边距
margin EdgeInsets 日历外边距
clipBehavior Clip 裁剪行为(默认Clip.hardEdge)

CalendarStyle 子属性

属性名 类型 说明
defaultTextStyle TextStyle 默认日期文本样式
selectedTextStyle TextStyle 选中日期文本样式
todayTextStyle TextStyle 今天日期文本样式
weekendTextStyle TextStyle 周末日期文本样式
outsideTextStyle TextStyle 显示范围外日期文本样式
disabledTextStyle TextStyle 禁用日期文本样式
selectedDecoration Decoration 选中日期装饰(背景等)
todayDecoration Decoration 今天日期装饰
defaultDecoration Decoration 默认日期装饰
weekendDecoration Decoration 周末日期装饰
outsideDecoration Decoration 范围外日期装饰
disabledDecoration Decoration 禁用日期装饰
rangeStartDecoration Decoration 范围选择起始日期装饰
rangeEndDecoration Decoration 范围选择结束日期装饰
rangeMiddleDecoration Decoration 范围选择中间日期装饰
markersDecoration Decoration 事件标记容器装饰
markersMaxCount int 最多显示的事件标记数量
markerSize double 事件标记大小
markersOffset PositionedOffset 事件标记偏移量
canMarkersOverflow bool 标记是否允许溢出日期单元格
outsideDaysVisible bool 是否显示范围外的日期(默认true)

HeaderStyle 子属性

属性名 类型 说明
formatButtonVisible bool 是否显示视图切换按钮(默认true)
formatButtonShowsNext bool 切换按钮是否显示下一个格式(默认true)
formatButtonDecoration BoxDecoration 视图切换按钮装饰
formatButtonTextStyle TextStyle 视图切换按钮文本样式
leftChevronIcon Widget 左箭头图标
rightChevronIcon Widget 右箭头图标
titleTextStyle TextStyle 标题(月份)文本样式
titleCentered bool 标题是否居中(默认false)
titleFormatter String Function(DateTime, dynamic) 标题文本格式化器
headerPadding EdgeInsets 表头内边距
headerMargin EdgeInsets 表头外边距
chevronPadding EdgeInsets 箭头图标内边距
chevronVisible bool 是否显示箭头图标(默认true)

样式定制示例:

TableCalendar(
  // ...其他参数
  calendarStyle: CalendarStyle(
    // 选中日期样式
    selectedDecoration: BoxDecoration(
      color: Colors.blue,
      shape: BoxShape.circle,
    ),
    // 今天日期样式
    todayDecoration: BoxDecoration(
      color: Colors.grey[200],
      shape: BoxShape.circle,
    ),
    // 周末样式
    weekendTextStyle: TextStyle(color: Colors.red),
    // 事件标记样式
    markersMaxCount: 3,  // 最多显示3个标记
    markerSize: 6,
  ),
  // 表头样式
  headerStyle: HeaderStyle(
    formatButtonVisible: false,  // 隐藏视图切换按钮
    titleCentered: true,
    headerPadding: EdgeInsets.symmetric(vertical: 8),
    titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
  ),
)

3.6. 本地化属性

属性名 类型 说明
locale String? 本地化语言代码(如'zh_CN'、'en_US')
daysOfWeekLabels List<String> 星期标签文本(默认按locale生成)
daysOfWeekLabelsExceptions Map<int, String> 特定星期几的文本覆盖
weekNumberLabel String 周数标签文本(默认'W')

示例:

import 'package:intl/intl.dart';
import 'package:intl/date_symbol_data_local.dart';

// 初始化本地化
initializeDateFormatting().then((_) {
  setState(() {});
});

TableCalendar(
  // ...其他参数
  locale: 'zh_CN',  // 支持 'en_US', 'ja_JP' 等
  daysOfWeekStyle: DaysOfWeekStyle(
    weekdayStyle: TextStyle(),
    weekendStyle: TextStyle(color: Colors.red),
  ),
)

3.7. 交互控制属性

属性名 类型 说明
enabledDayPredicate bool Function(DateTime) 判断日期是否可交互(默认全部可交互)
onHeaderTapped void Function(DateTime) 表头点击回调
onHeaderLongPressed void Function(DateTime) 表头长按回调
onDayLongPressed void Function(DateTime, DateTime) 日期长按回调
dragStartBehavior DragStartBehavior 拖拽起始行为(默认start)

3.8. 高级自定义属性

属性名 类型 说明
calendarBuilders CalendarBuilders 自定义构建器集合(以下为常用子项)
- dayBuilder Widget Function(BuildContext, DateTime, DateTime, bool, bool, bool, bool, List) 自定义日期单元格
- markerBuilder Widget Function(BuildContext, DateTime, List) 自定义事件标记
- headerTitleBuilder Widget Function(BuildContext, DateTime) 自定义表头标题
- weekNumberBuilder Widget Function(BuildContext, int) 自定义周数显示
transitionDuration Duration 视图切换动画时长(默认200ms)
pageAnimationEnabled bool 是否启用页面切换动画(默认true)

4. 性能优化

  • 使用 ValueNotifier 管理事件数据,避免不必要重建
  • 复杂标记使用缓存Widget
  • 合理设置 firstDaylastDay 范围
final ValueNotifier<List> _selectedEvents = ValueNotifier([]);

// 监听选中日期变化更新事件
void _updateSelectedEvents() {
  _selectedEvents.value = _events[_selectedDay] ?? [];
}

// 构建时使用ValueListenableBuilder
ValueListenableBuilder<List>(
  valueListenable: _selectedEvents,
  builder: (context, value, _) {
    return // 事件列表
  },
)

5. 完整配置示例

TableCalendar(
  firstDay: DateTime(2020),
  lastDay: DateTime(2030),
  focusedDay: _focusedDay,
  selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
  onDaySelected: (selectedDay, focusedDay) {
    if (!isSameDay(_selectedDay, selectedDay)) {
      setState(() {
        _selectedDay = selectedDay;
        _focusedDay = focusedDay;
      });
    }
  },
  // 视图配置
  calendarFormat: _calendarFormat,
  onFormatChanged: (format) => setState(() => _calendarFormat = format),
  availableCalendarFormats: const {
    CalendarFormat.month: '月',
    CalendarFormat.week: '周',
  },
  // 范围选择
  rangeStartDay: _rangeStart,
  rangeEndDay: _rangeEnd,
  rangeSelectionMode: _rangeSelectionMode,
  onRangeSelected: (start, end, focusedDay) => setState(() {
    _rangeStart = start;
    _rangeEnd = end;
    _rangeSelectionMode = RangeSelectionMode.toggledOn;
  }),
  // 事件加载
  eventLoader: (day) => _events[day] ?? [],
  // 样式定制
  calendarStyle: CalendarStyle(
    selectedDecoration: const BoxDecoration(
      color: Colors.blue,
      shape: BoxShape.circle,
    ),
    todayDecoration: BoxDecoration(
      color: Colors.grey[200],
      shape: BoxShape.circle,
    ),
    markersMaxCount: 3,
  ),
  headerStyle: const HeaderStyle(
    titleCentered: true,
    formatButtonVisible: false,
  ),
  // 本地化
  locale: 'zh_CN',
  // 自定义构建器
  calendarBuilders: CalendarBuilders(
    markerBuilder: (context, date, events) => events.isNotEmpty
        ? Container(
            width: 12,
            height: 12,
            decoration: const BoxDecoration(
              color: Colors.red,
              shape: BoxShape.circle,
            ),
          )
        : null,
  ),
)

6. 总结

table_calendar 提供了灵活的日历解决方案,核心关注:

  • 基础日期选择与范围选择的状态管理
  • 事件标记与自定义样式的视觉定制
  • 视图切换与本地化的用户体验优化
  • 性能优化技巧(如状态隔离、合理设置范围)

更复杂功能可参考官方文档Github仓库实现。使用时需注意范围选择与单个选择的互斥性,以及本地化配置的初始化步骤。


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

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

各位好,很久没出来发一发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官网

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

React 状态管理:Zustand 快速上手指南

React 状态管理:Zustand 快速上手指南

🤔 为什么需要 Zustand?

在 React 应用开发中,随着应用规模的扩大,组件间的状态管理会变得越来越复杂。传统的 useStateuseContext 在处理全局状态或复杂状态逻辑时,可能会遇到以下问题:

  • 状态更新复杂,需要手动处理引用比较
  • 跨组件状态共享需要多层 Context.Provider 嵌套
  • 状态逻辑难以复用和测试
  • 性能优化需要手动实现

Zustand 就是为了解决这些问题而生的!它是一个轻量级的 React 状态管理库,具有以下特点:

  • 🚀 轻量级:核心代码只有约 1KB,无外部依赖
  • 🔧 简单易用:无需 Provider 包裹整个应用
  • 🎯 灵活:支持函数式和类组件
  • 高性能:内置选择器优化,避免不必要的重新渲染
  • 🔄 异步支持:轻松处理异步状态更新
  • 📦 中间件支持:支持持久化、DevTools 等扩展功能

💡 Zustand 基础实现

1. 安装 Zustand

npm install zustand
# 或
yarn add zustand
# 或
pnpm add zustand

2. 创建 Store

Zustand 的核心是 create 函数,用于创建一个全局状态管理 store:

import { create } from 'zustand';

// 创建一个简单的计数器 store
const useCounterStore = create((set) => ({
  // 状态
  count: 0,
  // 操作状态的方法
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

export default useCounterStore;

这里的 create 函数接收一个函数作为参数,该函数返回一个包含状态和操作方法的对象。set 函数用于更新状态,它接收一个回调函数,回调函数的参数是当前状态,返回值是要更新的状态部分。

3. 在组件中使用 Store

在任何组件中,只需调用创建的 hook 即可访问和更新状态:

import React from 'react';
import useCounterStore from './store/counterStore';

const Counter = () => {
  // 直接从 store 中获取状态和方法
  const { count, increment, decrement, reset } = useCounterStore();

  return (
    <div>
      <h2>当前计数: {count}</h2>
      <div>
        +
        -
        重置
      </div>
    </div>
  );
};

export default Counter;

🚀 Zustand 进阶用法

1. 使用选择器优化性能

如果只需要 store 中的部分状态,可以使用选择器来避免不必要的重新渲染:

import React from 'react';
import useCounterStore from './store/counterStore';

const CountDisplay = () => {
  // 只订阅 count 状态,其他状态变化不会导致此组件重新渲染
  const count = useCounterStore((state) => state.count);

  return <div>当前计数: {count}</div>;
};

const CounterActions = () => {
  // 只订阅操作方法
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);

  return (
    <div>
      +
      -
    </div>
  );
};

2. 处理异步操作

Zustand 支持直接在 store 中处理异步操作:

import { create } from 'zustand';

// 创建一个包含异步操作的 store
const useUserStore = create((set) => ({
  users: [],
  loading: false,
  error: null,
  
  // 异步获取用户列表
  fetchUsers: async () => {
    set({ loading: true, error: null });
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/users');
      const users = await response.json();
      set({ users, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
}));

export default useUserStore;

在组件中使用:

import React, { useEffect } from 'react';
import useUserStore from './store/userStore';

const UserList = () => {
  const { users, loading, error, fetchUsers } = useUserStore();

  useEffect(() => {
    fetchUsers();
  }, [fetchUsers]);

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error}</div>;

  return (
    <ul>
      {users.map((user) => (
        <li>{user.name}</li>
      ))}
    </ul>
  );
};

export default UserList;

3. 使用中间件

Zustand 支持中间件扩展功能,例如持久化存储和 Redux DevTools 集成:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { devtools } from 'zustand/middleware';

// 创建一个带持久化和 DevTools 的 store
const useCounterStore = create(
  devtools(
    persist(
      (set) => ({
        count: 0,
        increment: () => set((state) => ({ count: state.count + 1 })),
        decrement: () => set((state) => ({ count: state.count - 1 })),
      }),
      {
        name: 'counter-storage', // 存储的键名
        storage: localStorage, // 存储方式 (localStorage 或 sessionStorage)
      }
    )
  )
);

export default useCounterStore;

4. 组合多个 Store

Zustand 支持将多个小 store 组合成一个大 store:

import { create } from 'zustand';
import { combine } from 'zustand/middleware';

// 创建两个独立的 store
const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

const useUserStore = create((set) => ({
  user: null,
  setUser: (user) => set({ user }),
}));

// 或者使用 combine 中间件组合状态
const useCombinedStore = create(
  combine(
    // 初始状态
    { count: 0, user: null },
    // 设置函数
    (set) => ({
      increment: () => set((state) => ({ count: state.count + 1 })),
      setUser: (user) => set({ user }),
    })
  )
);

📝 Zustand 最佳实践

  1. 保持 Store 简洁:每个 store 只负责管理相关的状态,避免创建过于庞大的 store
  2. 使用选择器:始终使用选择器来获取需要的状态,减少不必要的重新渲染
  3. 合理使用中间件:根据需求选择合适的中间件,避免引入不必要的依赖
  4. 处理异步错误:在异步操作中始终处理错误,避免应用崩溃
  5. 命名规范:使用 useXxxStore 的命名规范,便于识别和使用
  6. 类型安全:如果使用 TypeScript,可以为 store 添加类型定义,提高代码质量

🎯 Zustand 适用场景

Zustand 适用于以下场景:

  • 中小型 React 应用
  • 需要全局状态管理但不想使用复杂配置的项目
  • 希望减少样板代码的项目
  • 需要处理异步状态的应用
  • 希望使用轻量级状态管理库的项目

🔧 Zustand 与其他状态管理库的比较

特性 Zustand Redux Jotai
大小 ~1KB ~2KB ~2KB
学习曲线
Provider 需要
异步支持 内置 需要中间件 内置
DevTools 支持
持久化支持 需要中间件

🚀 总结

Zustand 是一个轻量级、简单易用的 React 状态管理库,它提供了丰富的功能和灵活的使用方式,同时保持了代码的简洁性。无论是中小型应用还是大型项目,Zustand 都能很好地满足状态管理的需求。

如果你正在寻找一个替代 Redux 的轻量级状态管理库,或者想要简化现有的状态管理逻辑,那么 Zustand 绝对值得一试!

下一篇文章,我们将介绍另一个优秀的 React 状态管理库——Jotai,敬请期待!✨

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 高级用法,让你的代码又快又好看》

下载视频

在命令行中下载视频,最常用且强大的工具是 youtube-dl(现已更名为 yt-dlp,功能更完善),它支持绝大多数视频网站,比如 YouTube、B 站、抖音等。

以下是具体的使用方法:

一、安装 yt-dlp

yt-dlp 是跨平台工具,支持 Windows、macOS、Linux。

  1. Windows 系统

    • 直接从 yt-dlp 官方 GitHub 仓库 下载 yt-dlp.exe
    • 将其放到容易找到的目录(比如 C:\tools),并把该目录添加到系统环境变量 Path 中,这样就能在命令行任意目录调用。
  2. macOS 系统使用 Homebrew 安装:

    brew install yt-dlp
    
  3. Linux 系统Ubuntu/Debian 系列:

    sudo apt update && sudo apt install yt-dlp
    

    其他发行版可从 GitHub 下载二进制文件。

二、基本下载命令

  1. 下载单个视频复制视频的网页链接,在命令行输入:

    yt-dlp [视频链接]
    

    示例(下载 B 站视频):

    yt-dlp https://www.bilibili.com/video/BV1xx411c7mZ
    

    视频会默认下载到当前命令行的工作目录。

  2. 指定视频格式和清晰度

    • 先查看视频支持的所有格式:

      yt-dlp -F [视频链接]
      

      输出会列出格式代码、分辨率、编码等信息,比如 248 对应 480p 视频,140 对应音频。

    • 下载指定格式的视频 + 音频(会自动合并):

      yt-dlp -f [视频格式代码]+[音频格式代码] [视频链接]
      

      示例(下载 1080p 视频):

      yt-dlp -f 137+140 https://www.youtube.com/watch?v=xxxxxx
      
  3. 下载整个播放列表复制播放列表链接,添加 -i 参数(忽略错误,防止个别视频下载失败中断):

    yt-dlp -i [播放列表链接]
    

三、注意事项

  1. 版权问题:仅可下载自己拥有版权或允许下载的视频,切勿用于侵权行为。

  2. 部分网站限制:一些网站有反爬机制,可能需要更新 yt-dlp 到最新版本:

    yt-dlp -U
    
  3. 需要 FFmpeg:如果要合并视频和音频、转换格式,需要安装 FFmpegyt-dlp 会自动调用它。


是否需要我帮你整理一份yt-dlp 常用参数速查表,方便你快速查询清晰度选择、批量下载等功能?

❌