阅读视图

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

🚀 Vue 一键转 React!企业后台 VuReact 混写迁移实战

在前端工程化落地过程中,Vue 与 React 生态的混合开发、存量项目迁移是很多团队都会遇到的痛点——既要复用历史业务代码,又想接入 React 生态的新能力,常规的“重写”方案成本高、风险大。

本文以客户支持协同后台为真实案例,基于 Vue 3 + Vue Router + Ant Design (React) + Zustand (React) 技术栈,手把手拆解 VuReact 实现 Vue 到 React “可控混写迁移”的全流程,从环境配置到业务验收一步到位,帮你低成本完成跨框架迁移。

核心差异:VuReact 并非 Veaury/Vuera 这类运行时“套壳”方案,而是通过语义级编译将 Vue DSL 转化为纯净的 React 代码,无运行时冗余、无框架耦合,最终产出可独立维护的 React 工程。

🎥 效果预览

  • 在线体验:skx7pn-5173.csb.app/
  • 源码仓库:github.com/vureact-js/…
  • 核心能力:编译后保留所有业务逻辑、路由守卫、状态联动,支持热更新,Vue 源码修改可同步更新 React 产物。

📋 前置准备

环境要求

  • Node.js ≥ 19(版本过低易导致依赖安装/编译失败)
  • 克隆示例仓库:
git clone https://github.com/vureact-js/example-customer-support-hub.git

初始化项目

cd customer-support-hub
npm install

安装完成后检查 package.json,确认包含 VuReact 核心编译脚本:

"scripts": {
  "vr:watch": "vureact watch",  // 增量迁移-监听模式
  "vr:build": "vureact build"   // 全量编译
}

同时确认项目根目录存在 vureact.config.ts(核心配置文件),路由入口需指向 Vue 路由文件:

router: {
  configFile: 'src/router/index.ts', // 声明Vue路由入口
},

🔧 核心步骤:VuReact 编译与产物解析

Step 1:执行编译(关键操作)

# 全量编译(首次迁移推荐)
npm run vr:build

# 增量迁移可选监听模式(开发阶段)
# npm run vr:watch

编译成功的核心特征

  1. 控制台输出编译统计(SFC/script/style 处理数量);

在这里插入图片描述

  1. 生成 .vureact/react-app 目录,目录结构与 Vue 源码完全一致;

在这里插入图片描述

  1. 样式自动注入(通过配置钩子修复路径):
// vureact.config.ts
onSuccess: async () => {
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
  const entryFile = path.resolve(__dirname, './.vureact/react-app/src/main.tsx');
  const data = fs.readFileSync(entryFile, 'utf-8');
  // 修复React入口样式导入路径
  const newData = data.replace('index.css', 'styles/app.css');
  fs.writeFileSync(entryFile, newData, 'utf-8');
};

常见编译失败排查

报错类型 快速排查方向
Network/NPM 错误 切换 npm 淘宝源,检查网络连通性
SFC 语法错误 先修复 Vue 源文件的模板插值、指令格式问题
产物目录缺失 确认在项目根目录执行命令,且 vureact.config.ts 配置正常

Step 2:React 产物核心逻辑解析

编译后的 React 工程完全复用 Vue 原有逻辑,核心适配层由 @vureact/runtime@vureact/router 支撑。

1. 路由系统适配

React 入口文件 main.tsx 通过 RouterProvider 挂载路由:

import RouterInstance from './router/index';
import { createRoot } from 'react-dom/client';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterInstance.RouterProvider />
  </StrictMode>,
);

Vue 路由守卫逻辑自动继承(比如未登录跳转登录):

// react-app/src/router/index.ts
import { createRouter, createWebHashHistory } from "@vureact/router";
import routes from './routes';
import { appStore } from '../store/useAppStore';

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

router.beforeEach((to, _from, next) => {
  // 放行公开页面(如登录页)
  if (to.meta.public) {
    next();
    return;
  }
  // 未登录跳转登录页
  const session = appStore.getState().session;
  if (!session.user) {
    next({
      name: 'login',
      query: { redirect: to.fullPath }
    });
    return;
  }
  next();
});

export default router;

2. 状态管理适配(Vue + Zustand)

Vue 源码中直接使用的 Zustand(React 状态库),编译后自动适配 React 语法:

// src/store/useAppStore.ts
import { createStore } from 'zustand/vanilla';

// 核心状态:会话、工单筛选、SLA配置等
export const appStore = createStore<AppState>((set) => ({
  session: { user: null },
  ticketFilters: { status: 'all' },
  activities: [],
  // 核心动作
  login: (user) => set((state) => ({ session: { ...state.session, user } })),
  appendActivity: (text) => set((state) => ({
    activities: [...state.activities, { id: Date.now(), text }]
  }))
}));

Vue 组件中订阅状态的逻辑,编译后也能正常工作:

// 原Vue代码
appStore.subscribe((state) => {
  userName.value = state.session.user?.name || '访客';
});

3. UI 组件适配(Vue 中用 Ant Design React)

Vue 源码中直接使用的 Ant Design React 组件,编译后自动转化为 React 语法:

<!-- 原Vue文件 src/pages/TicketsList.vue -->
<AntTable
  :columns="columns"
  :data-source="rows"
  :pagination="pagination"
  row-key="id"
  :loading="loading"
  @change="onTableChange"
/>

<AntDrawer 
  :open="drawerOpen" 
  width="560" 
  title="客户详情" 
  @close="onCloseDrawer"
>
  <!-- 客户信息展示 -->
</AntDrawer>

Step 3:启动 React 产物工程

# 进入编译后的React工程目录
cd .vureact/react-app

# 安装依赖(首次启动必做)
npm install

# 启动开发服务
npm run dev

启动成功特征

  • Vite 开发服务正常启动,浏览器打开默认进入登录页;

在这里插入图片描述

  • 登录后客服协同主界面样式、交互与原 Vue 项目完全一致;

在这里插入图片描述

  • 热更新生效:修改 Vue 源文件,npm run vr:watch 会同步更新 React 页面。

启动失败排查

  • 依赖缺失:根据日志补充 antdzustand 等包;
  • TS 报错:检查路由入口、运行时包的导入路径;
  • Vite 报错:确保 Node.js ≥ 19,或适当降级 Vite 版本。

✅ 业务闭环验收(核心验证)

迁移后需手动验证核心业务链路,确保功能完整:

  1. 登录与路由守卫:未登录访问业务页自动跳转登录,登录后可回跳目标页面;
  2. 工单全流程:筛选/接单/升级/状态更新正常,活动流、SLA 看板实时同步;
  3. 客户与建单联动:查看客户风险评分,快捷建单后工单列表可正常检索;
  4. 知识库检索:关键词/标签筛选、分页展示正常。

🚨 高效排错技巧(按症状)

问题症状 排查步骤
路由空白页 检查 RouterProvider 挂载逻辑,核对路由配置文件路径
编译失败 定位报错文件,修复 Vue 源码的语法/类型问题后重新编译
watch 模式不同步 确认 npm run vr:watch 正在运行,检查文件监听范围
业务联动不触发 检查 mock-api 调用(如 claimTicket),确认 appendActivity 执行

通用排错命令

# 重新全量编译
npm run vr:build

# 清空产物缓存后重新编译
rm -rf .vureact
npm run vr:build
cd .vureact/react-app && npm install && npm run dev

📌 核心能力覆盖(本案例)

技术维度 适配能力
模板语法 常用指令、事件、插槽全适配
组件系统 defineProps/defineEmits/slot 完全兼容
响应式 ref/computed/watch 编译为 React Hook
UI 库 Ant Design 表格/表单/抽屉/看板全量支持
状态管理 Zustand 跨页面状态同步、订阅更新
路由 守卫、嵌套路由、动态路由、重定向
样式 scoped 样式、Sass 语法适配
业务 工单流转、SLA 风险、客户评分、知识库检索

📚 后续学习导航

🎯 总结

VuReact 解决了 Vue 到 React 迁移的核心痛点:通过语义级编译将 Vue 源码转化为纯净的 React 工程,既复用了存量业务代码,又摆脱了框架耦合,最终产出可独立维护的 React 资产。

这套“可控混写迁移”方案,既降低了直接重写的成本与风险,又能平滑接入 React 生态,适合有存量 Vue 项目、想逐步迁移到 React 的团队落地。

🔗 相关资源

如果这个工具对你有帮助,欢迎给 GitHub 仓库点个 Star ✨,也欢迎在评论区交流迁移过程中遇到的问题~

详解 Nuxt 4 ,快速上手使用!

一、Nuxt 4 适用前提

Nuxt 4 适合的,不只是“想写 Vue 项目”的场景,而是“希望在 Vue 之上直接获得一整套成熟应用能力”的场景。它解决的核心问题不是单纯把页面跑起来,而是把路由、数据获取、服务端渲染、服务端接口、部署形态和工程组织一起收进同一个框架里。

以下场景通常很适合选择 Nuxt 4:

  • 你要做的是内容站、官网、博客、文档站、电商前台、中后台、SaaS 前端这类真正的网站或 Web 应用,而不是只做几个纯前端页面。
  • 你希望默认就具备 SSR、SEO、文件路由、布局系统、数据获取、服务端接口等能力,而不是自己从 Vue + Vite 一项项拼出来。
  • 你希望前后端边界更顺滑,例如前端页面和服务端 API 放在同一个仓库中协作。
  • 你需要根据不同页面选择 SSR、预渲染、缓存、重定向等渲染策略。
  • 你更看重“约定优于配置”的开发效率,希望团队新成员进入项目后能更快读懂结构。

以下场景则建议先评估:

  • 项目只是一个非常轻量的纯前端单页应用,没有 SEO、SSR、服务端逻辑需求,用 Vue + Vite 往往更直接。
  • 团队已经有成熟的纯前端架构和配套基础设施,Nuxt 带来的约定反而可能束缚既有体系。
  • 项目需要极度特殊的路由、渲染或服务端组织方式,而你不想遵循 Nuxt 的目录约定。

一句话概括:Nuxt 4 不是“Vue 的脚手架”,而是 Vue 生态里的全栈应用框架。

二、Nuxt 4 简介

Nuxt 4 是构建在 Vue 3 之上的全栈框架。它把现代 Web 应用里常见但又重复的能力预先组织好了,例如:

  • 文件系统路由
  • 布局系统
  • 自动导入
  • SSR 与 CSR
  • 服务端 API
  • 预渲染与混合渲染
  • 模块化扩展

Nuxt 4 的核心价值,在于它把这些能力整合成一个统一的开发体验。你写页面、写组件、写接口、写配置、写部署策略,都不再是彼此割裂的几套工具链,而是在同一个框架中完成。

从官方文档当前 4.x 版本可以明确确认几件很重要的事:

  • 新项目要求 Node.js 20.x 或更高版本
  • Nuxt 4 默认将应用源码放在 app/ 目录下。
  • 服务端能力由 Nitro 提供。
  • 数据获取、状态共享、自动导入、路由生成等能力是框架级设计,不是后期再补的插件习惯。

Nuxt 4 的核心能力

mindmap
  root((Nuxt 4))
    Vue 应用层
      页面路由
      布局系统
      组件自动导入
      中间件
    全栈能力
      SSR
      Server API
      数据获取
      useState
    服务端引擎
      Nitro
      routeRules
      prerender
      cache
    工程体验
      零碎配置更少
      目录约定清晰
      模块生态
      多种部署形态

三、Nuxt 4 与 Vue 的关系

Nuxt 经常被拿来和 Vue + Vite 一起讨论,但它们不是同一层次的工具。

方案 定位 优势 适合场景
Vue + Vite 前端应用基础组合 轻量、自由、上手快 纯前端 SPA、小型项目、已有成熟工程体系
Nuxt 4 Vue 全栈应用框架 路由、SSR、服务端、数据获取、部署策略一体化 官网、内容站、SaaS、需要 SEO 或 SSR 的 Vue 应用

如果你已经确定技术栈是 Vue,那么思考:

  • 你是否只需要 Vue + Vite 这样的基础前端组合。
  • 还是你已经进入“需要框架级约定和全栈能力”的阶段。

当项目出现下面这些需求时,Nuxt 的优势会非常明显:

  • 需要 SEO。
  • 需要 SSR 或预渲染。
  • 需要文件路由。
  • 需要服务端 API。
  • 需要更细粒度的页面级渲染控制。

四、Nuxt 4 快速上手

这一部分先解决“怎么把项目跑起来”,同时把几个最常见的理解误区顺手讲清楚。

1. 前置准备

根据 Nuxt 4 官方文档,建议准备:

  • Node.js 20.x 或更高版本,优先使用当前 LTS。
  • 一个具备 Vue 语言服务支持的编辑器。
  • 包管理器保持团队一致,本文以 pnpm 为例。
# 查看 Node.js 版本
node -v

# 查看 pnpm 版本
pnpm -v

如果你在 Windows 环境下感觉本地开发响应偏慢,官方文档也特别提醒了两点:

  • 可考虑使用 WSL 改善 HMR 和文件监听体验。
  • 浏览器访问本地开发服务时,使用 127.0.0.1:3000 往往会比 localhost:3000 更快。

2. 创建项目

# 创建 Nuxt 4 项目
pnpm create nuxt@latest my-nuxt-app

创建完成后进入目录:

cd my-nuxt-app

Nuxt 会根据模板生成基础项目结构。和很多旧教程不同,Nuxt 4 默认不是把页面代码全放在根目录,而是默认使用 app/ 目录作为应用源码目录。

3. 启动开发服务

pnpm dev -o

默认开发地址通常是:

http://localhost:3000

启动后你会立刻感受到 Nuxt 的几个默认体验:

  • 页面路由会根据目录自动生成。
  • 组件、组合式函数、工具函数有不少可以自动导入。
  • 页面切换和数据获取已经考虑了 SSR/CSR 之间的衔接。

4. 构建与预览

# 生产构建
pnpm build

# 本地预览构建结果
pnpm preview

和纯前端项目相比,Nuxt 的“构建结果”不只是静态资源这么简单。根据渲染模式不同,它可能包含:

  • 服务器运行所需产物
  • 预渲染页面
  • 客户端资源
  • payload 数据

因此,pnpm preview 比“只是看看页面能不能打开”更重要,它能帮助你提前发现渲染模式、资源路径和运行时配置相关的问题。

五、Nuxt 4 项目结构与目录认知

Nuxt 4 最大的学习成本,不在 API 本身,而在于你要先接受它的目录约定。目录一旦理解顺了,后面的很多能力都会自然变得清晰。

1. 一个典型的 Nuxt 4 目录结构

my-nuxt-app/
├── app/
│   ├── assets/              # 会进入构建流程的资源
│   ├── components/          # 组件
│   ├── composables/         # 组合式函数
│   ├── layouts/             # 布局
│   ├── middleware/          # 路由中间件
│   ├── pages/               # 页面路由
│   ├── plugins/             # Nuxt 插件
│   ├── utils/               # 工具函数
│   ├── app.config.ts        # 应用级公开配置
│   └── app.vue              # 应用根组件
├── public/                  # 原样公开的静态资源
├── server/
│   ├── api/                 # /api/* 接口
│   ├── middleware/          # 服务端中间件
│   ├── plugins/             # Nitro 插件
│   └── routes/              # 非 /api 前缀服务端路由
├── nuxt.config.ts           # Nuxt 核心配置
├── .env                     # Nuxt 读取的环境变量
├── package.json
└── tsconfig.json

这里最容易理解错的,是 app/public/server/ 三者的边界:

  • app/ 放的是 Vue 应用层代码。
  • public/ 放的是原样对外提供的静态资源。
  • server/ 放的是 Nitro 服务端逻辑。

如果把这三个目录的职责混在一起,后面几乎所有问题都会开始变得难排查。

2. app/ 是 Nuxt 4 的前台应用层

Nuxt 4 默认的 srcDirapp/。这意味着页面、组件、布局、组合式函数等前台应用代码,默认都应该往这里放。

可以简单理解为:

  • app/pages/ 决定页面路由。
  • app/layouts/ 决定页面外壳。
  • app/components/ 决定可复用视图单元。
  • app/composables/ 决定通用逻辑复用。
  • app/plugins/ 决定应用级注入与初始化。

3. server/ 是 Nuxt 的服务端能力入口

Nuxt 的服务端能力不是“顺手加了个 API 目录”,而是由 Nitro 提供的正式能力。

例如:

  • server/api/hello.ts 会生成 /api/hello
  • server/routes/health.ts 会生成 /health
  • server/middleware/log.ts 会在请求进入时执行

这意味着 Nuxt 项目天然就可以既写页面,又写服务端接口,不需要额外再搭一个独立的 Node 服务才能开始工作。

4. public/ 与 app/assets/ 的区别

这两个目录在所有前端框架里都容易让人混淆,在 Nuxt 中也一样:

  • public/ 中的资源不会经过构建转换,适合 favicon、robots.txt、静态下载文件这类稳定资源。
  • app/assets/ 中的资源会进入构建流程,更适合业务图片、样式资源、字体等。

如果一个资源你希望它保持稳定 URL,优先考虑 public/;如果你希望它参与构建优化、哈希命名、依赖分析,优先考虑 app/assets/

六、Nuxt 4 核心配置文件介绍

Nuxt 4 的配置理解难点,不在于“配置项很多”,而在于它有几层配置分别面向不同用途。

graph LR
    A[nuxt.config.ts] --> B[框架级配置]
    C[app/app.config.ts] --> D[应用公开配置]
    E[.env] --> F[runtimeConfig 环境变量注入]
    G[tsconfig.json] --> H[类型系统与编辑器体验]
    I[app/app.vue] --> J[应用根结构]

1. 文件 .env:环境变量

.env 文件本质上就是一个“给项目提供变量值”的配置文件。

mindmap
  root((环境变量))
    最常见的例子
      接口基础地址
      第三方服务密钥
      站点标题
      功能开关
    最适合放
      不能写死在代码里的值
      随环境变化的值
      多环境可切换的值

先看一个最简单的例子:

NUXT_API_SECRET=super-secret
NUXT_PUBLIC_API_BASE=https://api.example.com
NUXT_PUBLIC_SITE_NAME=我的 Nuxt 网站

你可以先把 .env 理解成“变量值仓库”。它只负责提供值,本身不负责告诉 Nuxt“这些值该怎么安全地在项目里使用”。这也是为什么后面还需要 runtimeConfig

值会怎么进入项目?

最常见的链路是这样的:

flowchart LR
    A[".env 文件"] -->|Nuxt 启动时把变量放到| B["process.env"]
    B -->|变量配置分类| D["nuxt.config.ts<br><b>runtimeConfig</b>"]
    D -->|业务代码读取| E["useRuntimeConfig"]

下面这句代码意思就是:

process.env.NUXT_API_SECRET
  • 去当前运行环境里读取名为 NUXT_API_SECRET 的环境变量
  • 这个值通常来自 .env,也可能来自系统环境变量或部署平台配置

为什么变量名前面经常有 NUXT_PUBLIC_

这是 Nuxt 用来区分“这个值能不能给前端看到”的重要约定。

你现在先记住最实用的一层就够了:

  • NUXT_PUBLIC_ 开头的,通常是准备给前端也能访问的值
  • 不带 NUXT_PUBLIC_ 的,通常更适合服务端私有使用

2. 文件 nuxt.config.ts:Nuxt 的总开关

这是 Nuxt 项目的核心配置入口。绝大部分全局能力,都应该优先从这里理解。

先看一个比前文更完整、也更接近真实项目的示例:

export default defineNuxtConfig({
  devtools: { enabled: true },
  modules: ['@pinia/nuxt', '@nuxtjs/tailwindcss'],
  css: ['~/assets/styles/main.scss'],
  app: {
    head: {
      title: 'Nuxt 4 Demo',
      meta: [
        { name: 'viewport', content: 'width=device-width, initial-scale=1' },
        { name: 'description', content: 'Nuxt 4 快速上手示例项目' }
      ]
    }
  },
  runtimeConfig: {
    apiSecret: process.env.NUXT_API_SECRET,
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api'
    }
  },
  routeRules: {
    '/': { prerender: true },
    '/blog/**': { prerender: true },
    '/admin/**': { ssr: false },
    '/api/**': { cache: { maxAge: 60 * 5 } }
  },
  vite: {
    server: {
      port: 3000
    }
  },
  nitro: {
    compressPublicAssets: true
  }
})
mindmap
  root((Config))
    modules  接入生态能力
    css      全局样式引入
    runtimeConfig  运行时参数管理
    routeRules     路由渲染与缓存
    vite           前端构建配置
    nitro          服务端引擎配置

(1)modules:接入 Nuxt 模块生态

它的作用很直接:给 Nuxt 安装并启用框架级能力扩展。

可以把它理解成“Nuxt 官方推荐的扩展入口”

  • 作用:一键集成第三方扩展包 / 插件,自动为项目注入功能、简化配置、拓展 Nuxt 核心能力,不用你手动写繁琐的初始化、注册、兼容代码。
  • 举例说明:
    1. @pinia/nuxt 自动在 Nuxt 中注册 Pinia,无需手动写 createPinia() + app.use();直接在项目任意组件 / 页面中使用 useStore,无需重复引入。
    2. @nuxtjs/tailwindcss 自动配置 Tailwind 依赖、PostCSS、样式注入;自动识别项目中的 Tailwind 类名,无需手动创建 tailwind.config.js 基础配置;

(2)css:注册全局样式

这里注册的是整个应用都会生效的全局样式文件。

如果只是某个组件自己的样式,依然优先放回组件内部;css 更适合“全项目共享”的样式入口。

(4)app:全局基础配置

它管的是整个网站通用、所有页面都生效的设置,不是某一个页面,是全站统一的规则。

这里只写了head,对应网页源码里的 <head> 标签,全站统一配置网页头部,不用每个页面单独写。

(3)runtimeConfig:管理运行时配置与环境变量

这项配置是 Nuxt 里“怎么安全、统一地读取环境配置”的标准入口;public 里的给前后端共用,外面的只留给服务端。专门存放不能写死在代码里、会随环境变化、敏感保密的配置(比如接口密钥、接口地址),运行时自动加载,不用改代码。

很多新手困惑:不是已经有 .env 了吗,为什么还要多这一层?

这里最容易踩的坑是:把敏感信息放进 public,或者误以为 .env 本身就是配置系统。

最简单的理解是:.env:负责“提供原始变量值”,runtimeConfig:负责“把变量按 Nuxt 的规则组织起来,供项目读取”;

也就是说,你可以把 .env 里的值先交给 runtimeConfig,然后在项目里统一通过 useRuntimeConfig() 去读取,而不是到处直接写 process.env.xxx

先看一个最常见、也最实用的例子:

NUXT_API_SECRET=super-secret
NUXT_PUBLIC_API_BASE=https://api.example.com
// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    apiSecret: process.env.NUXT_API_SECRET,
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api'
    }
  }
})

这段配置的意思其实很简单:

  • apiSecret 是服务端私有配置,例如第三方服务密钥。
  • public.apiBase 是公开配置,例如前端请求接口时要用的基础地址。

在代码里这样读取:

const config = useRuntimeConfig()

console.log(config.public.apiBase)

如果是在服务端代码里,还可以读取私有配置:

export default defineEventHandler(() => {
  const config = useRuntimeConfig()

  return {
    apiBase: config.public.apiBase,
    hasSecret: Boolean(config.apiSecret)
  }
})
graph LR
    A[".env"] --> C["nuxt.config.ts<br><b>runtimeConfig</b>"]
    
    C --> D["public"]
    C --> E["private"]
    
    D --> F["前端可读"]
    D --> G["服务端也可读"]
    D --> H["例子:api基础网址 / 网站标题"]
    
    E --> I["仅服务端可读"]
    E --> J["浏览器不可见"]
    E --> K["例子:api密码 / token / 数据库配置"]

(4)routeRules:控制页面级渲染与缓存策略

这项配置让你可以按路由粒度决定页面接口的行为,给网站里不同的网址路径,单独设置「怎么渲染、要不要缓存、能不能访问」的规则,不用全站统一设置,精准优化每个页面。

上面这个例子表达的意思分别是:

  • 首页构建时预渲染
  • 博客文章页也走预渲染
  • 后台页面关闭 SSR,走客户端渲染
  • 接口增加短时缓存

如果你在学 Nuxt 时只记住一个“和部署形态高度相关”的配置,那通常就是它。

(5)vite:向底层 Vite 传递配置

Nuxt 底层使用 Vite 作为开发和构建能力的一部分,因此当你确实需要改 Vite 行为时,可以从这里传配置。

常见场景包括:

  • 调整开发端口
  • 配置样式预处理器
  • 传递一部分 Vite 构建或开发参数

更稳妥的原则是:

  • 先找 Nuxt 自己有没有对应配置。
  • 只有 Nuxt 层没有、而底层 Vite 层确实需要改时,再用 vite

(6)nitro:向服务端引擎传递配置

nitro 是面向服务端引擎这一层的配置入口,常见用途包括:

  • 调整服务端输出行为
  • 配置压缩、缓存、部署相关细节
  • 对 Nitro 运行时做更底层的控制

它和 vite 的区别可以直接这样记:

  • vite 偏前端构建与开发链路
  • nitro 偏服务端运行与输出链路

对于刚接触 Nuxt 的读者来说,不需要一开始就深入 nitro 的所有细节,但至少要知道:Nuxt 不是只有 Vue 应用层配置,它还有服务端这一层。

如果想把这一节收束成最实用的判断原则,可以记住:

  • 和 Nuxt 整体行为相关的,先看 nuxt.config.ts
  • 和应用运行中要读到的公开配置相关的,再看 app.config.tsruntimeConfig

3. app/app.config.ts:公开且偏静态的应用配置

Nuxt 官方文档明确区分了 runtimeConfigapp.config。如果配置是:

  • 公开的,不敏感的
  • 更偏应用展示层
  • 更适合在构建期确定

那么它通常更适合放在 app/app.config.ts 里。

示例:

export default defineAppConfig({
  siteName: 'Nuxt 4 Demo',
  theme: {
    primaryColor: '#0ea5e9'
  }
})

在代码中读取:

const appConfig = useAppConfig()

如果你拿不准该放哪,可以这样判断:

  • 和密钥、环境变量、部署环境相关的,先想 runtimeConfig
  • 和站点标题、主题、公开开关相关的,先想 app.config

七、Nuxt 4 核心能力实操

这一部分不追求列全,而是优先讲 Nuxt 最有代表性的能力。

1. 文件系统路由

Nuxt 的页面路由来自 app/pages/ 目录。例如:

app/pages/
├── index.vue
├── about.vue
└── posts/
    └── [id].vue

它大致会生成这样的路由:

graph TD
    A["app/pages/index.vue"] --> B["/"]
    C["app/pages/about.vue"] --> D["/about"]
    E["app/pages/posts/[id].vue"] --> F["/posts/:id"]

这套规则的价值不只是“少写路由表”,而是让页面结构和 URL 结构天然对齐,项目越大越能体会到这种可读性。

2. 布局系统

布局放在 app/layouts/ 中,适合承载:

  • 顶部导航
  • 侧边栏
  • 页脚
  • 页面公共壳层

然后在 app.vue 中配合 <NuxtLayout> 使用。

如果某个页面需要特殊布局,也可以在页面中通过 definePageMeta 指定。这样做比“在每个页面里重复写头尾结构”更清晰,也更符合 Nuxt 的组织方式。

3. 自动导入

Nuxt 的自动导入是它最能提升开发手感的能力之一。根据官方文档,以下目录默认就有明显的自动导入能力:

  • app/components/
  • app/composables/
  • app/utils/

这意味着很多时候你不需要手动 import

<script setup lang="ts">
const count = useState('count', () => 0)
const doubled = computed(() => count.value * 2)
</script>

如果你更希望显式导入,Nuxt 也提供了 #imports 别名。

自动导入的好处很明显,但也要保持清醒:

  • 它提升了开发效率。
  • 但也会让“这个函数到底来自哪里”变得没那么直观。

所以团队协作里,通常建议对公共逻辑命名保持克制,不要让自动导入把语义搞得太散。

4. 数据获取

这一节是 Nuxt 最容易“看起来会用、实际上没用明白”的部分。因为在普通 Vue 项目里,大家很容易形成一种习惯:哪里要数据,就直接 fetch 一下。
但在 Nuxt 里,这样想往往不够,因为页面首屏数据获取要同时考虑:

  • 服务端渲染
  • payload 传递
  • hydration 复用
  • 避免客户端再次请求同一份数据
  1. $fetch 是基础能力,本质上是 Nuxt 中一个很强的同构请求工具,底层来自 ofetch。它能在服务端和客户端两边工作。你可以把它理解成:

    • 适合在服务端路由、插件、事件处理函数里直接使用

      • 但它本身不负责把 SSR 数据自动安全地传给客户端复用
  2. useAsyncData 是核心的 SSR 友好数据获取,是 Nuxt 数据获取体系里更底层、也更核心的组合式函数。它做的事情不是“帮你请求”,而是:

    • 包裹一个异步函数,在服务端执行它

    • 把结果放进 Nuxt 的数据传递链路

    • 在客户端 hydration 时复用这份结果,防止“二次获取”

    它有一个非常重要的点:需要一个唯一的key 来去重。

  3. useFetch 是最常用的便利层,可以理解成:useAsyncData + $fetch 的常用封装。当在页面或组件里“从某个 API 地址拿数据”时,它通常就是最自然的首选。

    它之所以常用,是因为:

    • 写法短

    • 自带 pendingerrorrefresh

    • 会根据 URL 和选项自动生成 key

    如果你只是想在页面里拿 /api/products 这种接口数据,先用 useFetch,通常就是最对路的选择。

SSR 友好的数据获取:Nuxt 怎么防止二次获取

Nuxt 通过“水合” (hydration) 过程来解决二次获取问题:数据在服务器上获取,页面被渲染成 HTML,获取到的数据被序列化并嵌入到 HTML 载荷(payload)中。在客户端,Nuxt 读取这个载荷并“水合”应用状态,从而避免了重新请求相同的数据。

flowchart LR
    A["服务端执行<br>useFetch/useAsyncData<br>获取数据"] --> C["渲染 HTML"]
    A --> D["写入 payload"]
    C --> E["返回浏览器"]
    D --> E
    E --> F["浏览器 hydration"]
    F --> G["复用 payload"]

这就是为什么 Nuxt 官方文档一直强调:页面初始化数据,不要在 <script setup> 里直接裸用 $fetch,否则很可能服务端请求一次,客户端 hydration 时又请求一次。

所以这一句一定要记住:$fetch 能请求数据,但不自动帮你解决 SSR 首屏复用;useFetchuseAsyncData 才会把数据接入 Nuxt 的 SSR 链路。

如果你想快速做判断,可以直接按这个规则记:

  • 页面首屏数据、希望 SSR 参与并防止二次获取:优先 useFetch
  • 页面首屏需要执行更灵活的异步逻辑:优先 useAsyncData
  • 点击按钮、提交表单、手动触发请求:直接用 $fetch
  • 页面初始化阶段,不要直接裸用 $fetch
  • useAsyncDatakey 一定要唯一

代码片段:最常见的 useFetch

<script setup lang="ts">
const { data: products, pending, error, refresh } = await useFetch('/api/products')
</script>

<template>
  <div v-if="pending">加载中...</div>
  <div v-else-if="error">错误: {{ error.message }}</div>
  <ul v-else>
    <li v-for="product in products" :key="product.id">{{ product.name }}</li>
  </ul>
  <button @click="refresh">刷新数据</button>
</template>

这个例子很典型,因为它基本覆盖了页面首屏取数最常见的需求:

  • data:取到的数据
  • pending:加载状态
  • error:错误状态
  • refresh:主动刷新

代码片段:useAsyncData 与唯一 key

<script setup lang="ts">
const route = useRoute()

const { data: post } = await useAsyncData(
  `post-${route.params.slug}`,
  () => $fetch(`/api/posts/${route.params.slug}`)
)
</script>

这段代码里最重要的不是 $fetch,而是前面的这个 key:

`post-${route.params.slug}`

虽然 useFetch 往往会自动生成 key,但对 useAsyncData,或者对 useFetch 那些 URL 本身不够独特的场景,手动提供清晰且唯一的 key 非常重要。

5. 状态共享

useState 本质上也是响应式状态,但它比普通 ref 多了一层 Nuxt 的 SSR 友好能力。可以把它理解成:

  • 它像 ref,会在使用相同 key 的地方共享状态
  • 但它会参与 Nuxt 的服务端到客户端状态传递
  • 它能在 hydration 时复用服务端已经生成好的状态,解决“水合不匹配”

所以它解决的根本问题,不只是“共享”,而是:让客户端以和服务端渲染时完全一致的初始状态启动。

如果没有这层机制,就很容易出现“服务端渲染出的是 A,客户端接管时算出来的是 B”,最终引发 hydration 不匹配。

最佳实践:把 useState 包装成组合式函数

而是把它包进一个组合式函数里。这样做的好处是:key 集中不易乱、类型更稳定、更容易复用

// app/composables/useCounter.ts
export const useCounter = () => useState<number>('counter', () => 0)
// 创建一个名为 `counter` 的共享状态,用 `0` 作为初始值
// 之后其他地方只要使用同样的 key,就会拿到同一份状态

然后在组件里这样使用:

<!-- app/components/TheCounter.vue -->
<script setup lang="ts">
const counter = useCounter()
</script>

<template>
  <div>
    <p>计数器: {{ counter }}</p>
    <button @click="counter++">+</button>
  </div>
</template>

代码注意事项

  • 不要在文件顶层直接用 ref 做全局状态

    这一点非常重要,尤其是对刚从普通 Vue 项目切过来的同学。下面这种写法在 Nuxt 的通用渲染应用里是有风险的:

    const counter = ref(0)
    

    如果它出现在文件顶层作用域,就可能变成服务端进程里的单例状态。这样一来,不同用户请求之间就有机会共享同一份状态,严重时甚至可能导致数据泄露。

  • useState 里的值要可序列化

    因为 useState 的值需要从服务端传到客户端,所以它本质上也会进入序列化流程。

    这意味着你放进去的数据最好是可序列化的,例如:字符串、数字、布尔值、数组、普通对象

    不推荐直接放进去的有:函数、类实例、带复杂原型链的对象

    可以简单理解成:能安全“打包后再还原”的数据,更适合放进 useState

扩展:何时以及为何集成 Pinia

useState 非常适合处理简单到中等复杂度的状态。对于复杂的全局状态管理,特别是当需要 actions、getters 以及 Vue DevTools 的时间旅行调试等高级功能时,那么官方推荐的方向就是 Pinia。Nuxt 也提供了专门的 Pinia 模块来帮你处理 SSR 集成。

你可以直接把两者理解成:

  • useState:轻量、直接、SSR 友好,适合大多数简单共享状态
  • Pinia:更完整的状态管理方案,适合复杂全局状态

6. 服务端 API

Nuxt 项目可以直接在 server/ 目录中写接口,通过文件命名约定来处理不同的 HTTP 方法。

  • 基本的 GET 端点:

    // server/api/hello.get.ts
    export default defineEventHandler(() => {
      return {message: 'hello from server'}
    })
    

    页面中直接调用:

    <script setup lang="ts">
    const { data } = await useFetch('/api/hello')
    </script>
    
  • 基本的 POST 端点:

    // server/api/users.post.ts
    export default defineEventHandler(async (event) => {
      const body = await readBody(event);
      // 创建新用户的逻辑...
      console.log('新用户:', body);
      setResponseStatus(event, 201); // 设置 HTTP 状态码
      return { success: true, user: body };
    });
    

这套体验的价值在于:

  • 前端页面和接口距离更近。
  • 本地联调成本更低。
  • 对中小型项目尤其友好。

很多人会说 “Nitro 就是 Nuxt 的服务端引擎”,这句话没错,但还不够完整。更准确地说,Nitro 是 Nuxt 的服务端运行时,负责接收请求、执行服务端逻辑、处理接口、参与 SSR 渲染、应用 routeRules,并生成可部署的服务端运行结果。

它主要承担这些核心能力:server/api/server/routes/ 路由处理、server/middleware/ 中间件、server/plugins/ 插件、SSR 服务端渲染执行、缓存、预渲染、重定向等路由规则,以及最终 .output/server 运行时产物的构建。

可以简单理解为:Nuxt 负责应用框架层面,Nitro 负责服务端执行层面,Nuxt 将 Vue 应用层与 Nitro 服务端层整合在了同一套工作流中。

Nitro 其一大核心优势便是同构 fetch 优化。在 SSR 渲染阶段调用内部 API 路由(如 useFetch('/api/hello'))时,Nuxt 并不会发起真实的 HTTP 网络请求,而是直接在当前进程内调用对应的事件处理函数。

这种机制彻底消除了网络开销与延迟,带来了显著的性能提升,这是传统前后端分离部署难以实现的。因此,在 Nuxt 中将 API 与前端代码同构存放,不只是开发上的便捷性,更是为了在服务端渲染阶段获得实打实的性能增益,也是 Nuxt 全栈方案的核心竞争力之一。

7. 服务端中间件

如果你需要在所有请求进入前做日志、鉴权上下文注入等处理,还可以使用 server/middleware/

// server/middleware/logger.ts
export default defineEventHandler((event) => {
  console.log(`[${event.method}] 新请求: ${getRequestURL(event).pathname}`);
});

代码注意事项:服务器中间件不应返回值或结束响应,其职责是修改 event 上下文或执行副作用操作。若返回值会导致请求短路,使其无法到达目标处理器。

8. 客户端路由中间件

对新手而言,服务器中间件(server/middleware/)和路由中间件(app/middleware/)的区别很容易混淆。

服务器中间件运行在 Nitro 服务端层面,处理原始 HTTP 请求,对所有请求生效,包括 API 与静态资源请求。

路由中间件则基于 Vue 和 vue-router 运行,在客户端或服务端页面导航时执行,不会作用于直接的 API 调用。

例如,路由中间件一个简单的登录保护:

// app/middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
  const user = useState('user')
  if (!user.value) {
    return navigateTo('/login')
  }
})

页面中使用:

<script setup lang="ts">
definePageMeta({
  middleware: 'auth'
})
</script>

9. Head 管理基础

在 Nuxt 里,页面 <head> 相关内容不是后期随便拼接的,而是框架级能力。
从使用角度看,可以先分成两层:

  • 全局层:例如 nuxt.config.ts 里的 app.head,适合放全站通用的标题模板、基础 meta、favicon。
  • 页面层:例如在页面或组件里使用 useHead / useSeoMeta,适合根据当前页面数据动态设置标题和描述。

可以简单理解成:

  • 全局层解决“整个站点默认长什么样”
  • 页面层解决“当前这个页面要展示什么头部信息”

如果只写全局 app.head,当然能让站点具备基础头部信息;但如果你要真正做好 SEO,尤其是商品页、文章页、详情页这种“每页内容都不同”的页面,就必须进入页面级动态管理。

10. SEO 与元数据管理

Nuxt 的 SSR 基础天然对 SEO 友好,因为搜索引擎拿到的不是一个空壳 HTML,而是已经包含页面内容的首屏结构。
但 SSR 只是基础,真正把 SEO 做细,还需要把标题、描述、Open Graph、Twitter Card 这些元数据管起来。

这里最值得先掌握的三个工具是:

  • useHead
  • useSeoMeta
  • useHeadSafe

useHead:通用的 Head 管理工具

useHead 是最基础、也最通用的组合式函数。只要是合法的 head 标签内容,它基本都能管理,例如:titlemetalinkscripthtmlAttrsbodyAttrs

比如:

<script setup lang="ts">
useHead({
  title: '商品详情页',
  meta: [
    { name: 'description', content: '这是商品详情页' }
  ]
})
</script>

你可以把它理解成:

  • 能力最全
  • 适合需要自己精细控制 head 结构时使用
  • 但写法也相对更底层、更自由

useSeoMeta:更推荐的 SEO 写法

如果你的目标主要是 SEO,而不是任意 head 标签管理,那么更推荐优先使用 useSeoMeta

它的特点是:

  • 更贴近 SEO 场景
  • 类型更清晰
  • 能减少常见拼写错误

例如你不用自己纠结:

  • 这里应该写 name
  • 还是应该写 property

而是直接写更语义化的字段:titledescriptionogTitleogDescriptionogImagetwitterCard

所以从教程角度,可以直接给一个很实用的结论:

  • 想通用控制 head:用 useHead
  • 想专门做 SEO 元数据:优先 useSeoMeta

动态 SEO:产品页是最典型的场景

Nuxt 真正体现优势的地方,不是“能写一个静态 title”,而是可以结合页面数据,动态生成每个页面自己的 SEO 信息。

例如商品详情页:

<script setup lang="ts">
const { data: product } = await useFetch('/api/products/some-product')

useSeoMeta({
  title: () => `${product.value?.name} - 我的商店`,
  description: () => product.value?.description,
  ogTitle: () => `${product.value?.name} - 我的商店`,
  ogDescription: () => product.value?.description,
  ogImage: () => product.value?.imageUrl,
  twitterCard: 'summary_large_image'
})
</script>

这段代码最重要的不是 API 写法,而是它体现出的思路:

  • 页面数据先通过 useFetch 获取
  • SEO 标签再根据这份数据动态生成
  • 因为 Nuxt 支持 SSR,所以搜索引擎拿到的首屏里就已经包含这些元数据

这正是 Nuxt 对 SEO 友好的关键原因之一。

什么时候要用 useHeadSafe

如果你处理的是用户生成内容,或者来源不完全可控的数据,例如:

  • 用户输入的标题
  • CMS 后台可编辑的描述
  • 外部系统返回的富文本摘要

那么在把这些内容放进 head 时,要特别注意安全问题。

这时更适合使用 useHeadSafe,因为它会对输入内容做更安全的处理,避免把危险属性或值直接渲染进页面头部,从而降低 XSS 风险。

可以简单理解成:

  • 普通可控内容:useHead / useSeoMeta
  • 用户生成或不完全可信内容:优先考虑 useHeadSafe

实战里的最实用建议

如果你希望这部分先能落地,而不是一下子记一堆 API,可以先记住下面几条:

  • 全站默认头部放 app.head
  • 页面级动态 SEO 优先用 useSeoMeta
  • 需要更细粒度 head 控制时再用 useHead
  • 页面 SEO 最好绑定真实页面数据,而不是写死模板文本
  • 用户生成内容进入 head 时,优先考虑 useHeadSafe

八、Nuxt 4 渲染模式与部署思路

Nuxt 真正拉开和普通前端项目差距的地方,就在这里。你不只是“构建一个站点”,而是在决定每条路由应该怎样被渲染。

1. 默认并不只是 SPA

Nuxt 的默认优势之一,就是它天然适合 SSR。你不用先把 Vue 项目搭起来,再额外拼接 SSR 方案。

但 Nuxt 也不只支持 SSR,它可以在同一个项目里组合多种策略:

  • SSR(Server-Side Rendering,服务端渲染)
  • CSR(Client-Side Rendering,客户端渲染)
  • 预渲染
  • 混合渲染
  • Edge 部署

2. routeRules 是理解 Nuxt 渲染能力的关键

Nuxt 官方文档明确指出,Nitro 的 routeRules 可以对不同路径设置不同规则。

示例:

export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/blog/**': { prerender: true },
    '/api/**': { cache: { maxAge: 60 * 60 } },
    '/old-page': {
      redirect: {
        to: '/new-page',
        statusCode: 302
      }
    }
  }
})

这意味着同一个 Nuxt 项目里,你完全可以让:

  • 首页在构建时预渲染
  • 博客页按规则预渲染
  • 某些接口带缓存
  • 某些旧地址自动重定向

这就是 Nuxt 的“混合渲染”思路,它比“整个站点只有 SSR 或只有 SPA”灵活得多。

3. 预渲染不只是“导出静态 HTML”

根据官方文档,Nuxt 在预渲染时还会生成 _payload.json,其中包含 useAsyncDatauseFetch 产生的序列化数据。客户端导航时可以直接读取这些 payload,而不是重复请求。

这也是为什么前面一直强调:

  • 数据获取方式和渲染模式是连在一起的。
  • 不能把 Nuxt 中的数据获取简单当作普通前端里的异步请求。

九、Nuxt 4 核心架构与工作流程

这一章不再按“功能清单”来讲,而是按 Nuxt 真正的工作方式来拆。重点是把 Nuxt 的生成层、运行层、服务端层、客户端接管层,以及这些层之间怎么衔接讲清楚,尤其补清楚两个经常被讲虚的点:Nuxt 到底扫描了什么,以及应用真正的入口链路是什么。

1. Nuxt 到底是什么架构,不只是“Vue + SSR”

一个比较完整、能拿分的回答应该是:

Nuxt 不是简单把 Vue 套上 SSR,而是把 Vue 应用层、服务端引擎、构建生成层和部署产物层组织成统一工作流的全栈框架。

它至少可以拆成 4 层:

  • app/:Vue 应用层,放页面、布局、组件、composables、插件
  • server/:Nitro 服务端层,放 API、server middleware、server plugins、routes
  • .nuxt/:生成层,把约定式源码整理成可运行的应用骨架
  • .output/:部署层,生产环境真正运行的产物
graph TD
    A["源码"] --> B["app/"]
    A --> C["server/"]
    A --> D["nuxt.config.ts"]
    B --> E["Vue 应用层"]
    C --> F["Nitro 服务端层"]
    D --> G["全局配置层"]
    E --> H["Nuxt 生成层 .nuxt"]
    F --> H
    G --> H
    H --> I["开发运行"]
    H --> J["生产构建"]
    J --> K["部署产物 .output"]

2. Nuxt 到底扫描了什么:扫描对象、规则和结果

很多教程会说“Nuxt 会扫描目录”,但如果不继续说清楚“扫描什么、按什么规则扫描、扫描后拿这些结果做什么”,这一句其实帮助不大。

Nuxt 扫描的不是整个项目里所有文件,而是被框架约定为有特殊语义的目录和文件。最重要的几类包括:

  • app/pages/:扫描后生成页面路由
  • app/layouts/:扫描后生成布局映射
  • app/middleware/:扫描后生成客户端路由中间件映射
  • app/plugins/:扫描并自动注册 Nuxt 插件
  • app/components/:扫描后生成组件自动导入能力
  • app/composables/app/utils/:扫描后生成自动导入声明
  • server/api/:扫描后生成 /api/* 服务端路由
  • server/routes/:扫描后生成普通服务端路由
  • server/middleware/:扫描后挂载到 Nitro 请求链路
  • server/plugins/:扫描后在 Nitro 启动时执行

这里最关键的细节是:不同目录的扫描规则并不完全一样。

例如 app/plugins/ 并不是“递归扫描一切文件并全部注册”。官方文档明确说明:

  • 默认自动注册的是顶层插件文件
  • 子目录下的 index 文件目前也会被扫描,但这种形式已经不推荐长期依赖
  • 也就是说,自动注册是有边界和规则的,不是无差别递归

所以更准确地说,Nuxt 的扫描是:

  • 以目录约定为边界
  • 以文件位置和类型为规则
  • 以生成 .nuxt 中间结果为目的
flowchart LR
    A["app/ + server/ + nuxt.config.ts"] --> B["Nuxt 扫描器"]
    B --> C["路由记录"]
    B --> D["布局与中间件映射"]
    B --> E["插件注册表"]
    B --> F["自动导入声明"]
    B --> G["Nitro 路由与处理器"]
    C --> H["写入 .nuxt"]
    D --> H
    E --> H
    F --> H
    G --> H

3. 应用真正的入口是什么

“Nuxt 的入口是什么”这个问题特别容易回答得似是而非。

从应用视图树的角度看,app/app.vue 是 Nuxt 应用的根组件入口。官方文档也明确把 app.vue 定义为 Nuxt application 的 main component。

但这里一定要分清两个层次:

视图根入口:app.vue

app.vue 决定的是:

  • 整个应用最外层长什么样
  • 布局体系如何包裹页面
  • 当前页面最终渲染到哪里

最常见的写法是:

<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

这里三者职责分别是:

  • app.vue:应用根组件
  • <NuxtLayout>:当前页面外面套哪层布局
  • <NuxtPage>:当前路由对应的页面组件渲染到哪里

这也解释了一个很容易忽略的点:

  • app/pages/ 只是被扫描并生成路由记录
  • 真正把当前页面显示出来的,是 app.vue 里的 <NuxtPage />

框架运行入口:.nuxt 里的生成入口

Nuxt 框架当然不是“从你手写的 app.vue 文件直接启动”的。更准确的说法是:

  • Nuxt 先生成自己的运行入口
  • 这个入口会把 app.vue 接成根组件
  • 然后再由 Nuxt / Vue 创建应用实例

所以要把这两层区分开:

  • app.vue 是应用视图树入口
  • .nuxt 里的生成入口是框架真正的运行入口

4. 执行 pnpm dev 后,Nuxt 内部到底发生了什么

这题不要回答成“就启动开发服务器”。更完整的回答是:

  1. 读取 nuxt.config.ts
  2. 扫描 app/server/
  3. 生成 .nuxt 与类型
  4. 启动开发服务器
  5. 等待请求进入
  6. 请求进入后,由 Nitro 和 Nuxt 一起处理 SSR/CSR 链路

更直观的流程可以看这张图:

flowchart TD
    subgraph 启动阶段
        direction LR
        A["执行 pnpm dev"] --> B["读取<br>nuxt.config.ts"]
        B --> C["扫描 app/ 和 server/"]
        C --> D["生成 .nuxt 与类型"]
        D --> E["启动 Nuxt Dev Server"]
    end

    subgraph 请求阶段
        direction LR
        F["浏览器请求页面"] --> G["Nitro<br>接收请求"]
        G --> H["创建<br>Nuxt实例<br>Vue 实例"]
        H --> I["执行<br>app<br>plugins<br>服务端相关逻辑"]
        I --> J["执行<br>页面校验<br>app middleware"]
        J --> K["渲染页面组件"]
        K --> L["执行<br>useFetch<br>useAsyncData"]
        L --> M["生成<br>HTML<br>payload"]
    end

    启动阶段 --> 请求阶段
    请求阶段 --> N["返回内容到浏览器"]
    N--> O["浏览器 hydration"]

这里最值得抓住的两个点通常是:

  • Nuxt 开发时不是直接跑源码,而是先生成 .nuxt
  • 请求进来不是直接进 Vue 组件,而是先过 Nitro,再进入 Nuxt 应用渲染链路

5. 一次页面请求进来以后,Nuxt 内部的执行顺序

按官方 Lifecycle 文档,可以整理成更适合理解框架执行链路的顺序:

服务启动时执行的部分

  • Nitro 启动
  • 执行 server/plugins/
  • 注册服务端钩子和运行时扩展

这里容易被问到一个细节:

  • server/plugins/ 更接近服务端启动初始化
  • 不是每个页面请求都重新执行一次的页面逻辑

每个请求都会走的部分

  1. 请求进入 Nitro
  2. 执行 server/middleware/
  3. 创建 Nuxt 与 Vue 应用实例
  4. 执行 app/plugins/
  5. 执行页面 validate
  6. 执行 app/middleware/
  7. 匹配布局、页面与组件树
  8. 执行 useFetch / useAsyncData
  9. 生成 HTML
  10. 把 HTML、payload、资源信息返回浏览器

这套顺序非常值得记,因为它能帮你回答很多追问:

  • server/middleware/app/middleware/ 有什么区别
  • 为什么插件比页面先执行
  • 为什么 useFetch 能参与 SSR

6. .nuxt 是什么:先生成再运行

官方文档明确说明,Nuxt 会生成 .nuxt/ 目录,而 nuxt prepare 也会专门创建 .nuxt 并生成类型。
这说明 .nuxt 不是无意义缓存,而是 Nuxt 的中间生成层

你可以这样答:

.nuxt 是 Nuxt 根据 app/server/nuxt.config.ts 等约定式源码,自动生成出来的可运行应用骨架。

它通常承载这些东西:

  • 路由生成结果
  • 自动导入声明
  • 类型文件
  • 插件注册结果
  • 应用运行入口
flowchart LR
    A["app/<br>server/<br>nuxt.config.ts"] --> B["Nuxt 扫描"]
    B --> C["生成 .nuxt"]
    C --> D["路由定义"]
    C --> E["自动导入声明"]
    C --> F["类型文件"]
    C --> G["插件注册和运行入口"]

换成更底层的理解方式,可以直接这样记:

  • 因为 Nuxt 先扫描目录约定
  • 再把结果生成进 .nuxt
  • 最后运行的其实是生成后的应用结构

7. .nuxt 和 .output 有什么区别

这题很经典,因为它能测出你有没有真正理解生成层和部署层。

标准区分方式是:

  • .nuxt:开发期 / 生成期的中间结果
  • .output:生产构建后真正部署和运行的最终结果
flowchart LR
    A["源码与配置"] --> B["nuxt build"]
    B --> C["生成客户端资源"]
    B --> D["生成 Nitro 服务端产物"]
    B --> E["应用 routeRules / prerender"]
    C --> F[".output"]
    D --> F
    E --> F

所以:

  • .nuxt 更像“运行前整理好的应用骨架”
  • .output 更像“真正拿去上线部署的产物”

官方部署文档也直接给出运行方式,例如:

node .output/server/index.mjs

这说明生产环境真正跑的不是源码目录,而是 .output

React&Vue知识点汇总

Vue

1. 声明式渲染

  • 模板语法{{ }} 文本插值、v-html 输出 HTML

  • 指令

    • 内置指令:v-bindv-onv-modelv-if/v-else-if/v-elsev-showv-forv-prev-cloakv-once
    • 自定义指令:全局 Vue.directive(Vue 2)/ app.directive(Vue 3),局部 directives 选项

2. 响应式系统

  • Vue 2:基于 Object.defineProperty,递归遍历对象属性,无法检测

    • 对象属性的添加/删除(需用 Vue.set / this.$set
    • 数组索引修改(需用变异方法或 Vue.set
    • 数组长度变化(需用 splice 等)
  • Vue 3:基于 Proxy,可监听动态添加属性、数组索引修改,性能更优,支持 MapSet 等原生集合的响应式

  • 响应式原理

    • 数据劫持:Vue 对 data 中的属性进行响应式处理(Vue 2 用 Object.defineProperty,Vue 3 用 Proxy)。
    • 依赖收集:当组件渲染或计算属性等执行时,会访问响应式数据,此时将当前正在执行的 Watcher(观察者)添加到该数据的依赖列表(Dep)中。
    • 派发更新:当数据被修改时,Dep 通知所有依赖它的 Watcher 执行更新。
    • 异步更新队列Watcher 更新时并不立即执行 DOM 操作,而是将自身推入一个队列,在下一个事件循环(microtask)中统一执行,并利用 nextTick 提供更新后的回调。

3. vue3 API

API 说明
ref() 声明任意类型的响应式数据,需通过 .value 访问。
reactive() 声明对象/数组类型的响应式数据,可直接访问属性。
computed() 定义计算属性,基于响应式依赖缓存结果。
watch() 监听特定数据源,在数据变化时执行副作用。
watchEffect() 自动追踪其内部使用的响应式数据,并在数据变化时立即重新运行。
onMounted()onUpdated()onUnmounted() 等 组件生命周期不同阶段执行的钩子函数,用法与选项式 API 对应。
provide()inject() 用于跨层级组件通信,祖先组件提供数据,后代组件注入使用。
toRefs()toRef()isRef()unref() 等 用于处理 ref / reactive 对象的辅助函数,帮助进行响应式转换和判断。
defineProps()defineEmits() 在 <script setup> 中声明组件的 props 和 emits,享受完整类型推导。
defineExpose() 在 <script setup> 中声明当前组件暴露给父组件的属性或方法。
defineOptions() 在 <script setup> 中声明组件名 (name) 或 inheritAttrs 等选项。

内置组件

组件 说明
<component> 用于动态渲染不同组件的“元组件”,通过 is 属性决定
<transition> 为单个元素或组件添加进入/离开过渡动画
<transition-group> 为列表中的多个元素或组件添加过渡动画
<keep-alive> 缓存动态组件,避免重复渲染和状态丢失
<teleport> 将组件模板的一部分渲染到 DOM 树中的指定位置
<suspense> 管理异步组件或依赖异步数据的组件,在等待时显示后备内容
<slot> 作为组件模板中的插槽出口,接收父组件分发的内容

内置指令

指令 说明
v-model 在表单元素或组件上创建双向绑定。
v-ifv-else-ifv-else 条件渲染,为 false 时不渲染元素。
v-show 条件渲染,通过 CSS 的 display 属性切换。
v-for 基于源数据多次渲染元素或模板块。
v-on (@) 绑定事件监听器。
v-bind (:) 动态地绑定一个或多个属性。
v-slot (#) 用于声明具名插槽或作用域插槽。

4. 生命周期钩子

阶段 Vue 2 钩子 Vue 3 钩子(Options API) Vue 3 钩子(Composition API)
初始化 beforeCreate, created setup() 代替 beforeCreate/created
挂载 beforeMount, mounted onBeforeMount, onMounted
更新 beforeUpdate, updated onBeforeUpdate, onUpdated
卸载 beforeDestroy, destroyed beforeUnmount, unmounted onBeforeUnmount, onUnmounted
错误捕获 errorCaptured onErrorCaptured
其他 activated, deactivated(keep-alive) onActivated, onDeactivated
调试 renderTracked, renderTriggered(开发) onRenderTracked, onRenderTriggered

5. 插槽

  • 默认插槽、具名插槽、作用域插槽(slot-scope in Vue 2,v-slot in Vue 2.6+ & Vue 3)
  • Vue 3 中 v-slot 统一为指令语法,slot 和 slot-scope 被废弃

6. 混入(Mixin)

  • 全局混入、局部混入
  • 合并策略:数据递归合并,同名钩子合并为数组,方法/组件/指令等直接覆盖
  • 缺点:命名冲突、隐式依赖、代码不直观 → 推荐组合式 API 替代

7. 自定义指令

  • 钩子函数:

    • Vue 2:bindinsertedupdatecomponentUpdatedunbind
    • Vue 3:beforeMountmountedbeforeUpdateupdatedbeforeUnmountunmounted
  • 参数:elbindingvnodeprevVnode

8. 过滤器(Filters)

  • Vue 2 支持模板内过滤器({{ msg | filter }})及全局/局部定义
  • Vue 3 中移除,推荐用计算属性或方法替代

9. 动画与过渡

  • <transition> 单元素过渡
  • <transition-group> 多元素/列表过渡
  • 类名约定:v-enter-from/v-enter-to 等(Vue 3 命名变化)
  • JavaScript 钩子:@before-enter@enter@after-enter 等

10、组件通信方式(详细对比)

方式 Vue 2 Vue 3
props / $emit 支持 支持,emit 需在 setup 中声明
v-model 单个,value + input 可多个,modelValue + update:modelValue,支持自定义修饰符
refs/refs / parent / $children $children 存在 移除 $children,推荐 ref + $parent 或组合式 API
provide / inject 默认非响应式,可传递响应式对象 支持响应式传递,可提供 ref/reactive
event bus new Vue() 作为总线 推荐用 mitt 等第三方库
Vuex Vuex 3 Vuex 4 / Pinia
slot 作用域 slot-scope v-slot 统一语法
组合式 API 可直接使用 ref 传递,逻辑复用更灵活

11、Vue Router 对比(3.x vs 4.x)

特性 Vue Router 3(Vue 2) Vue Router 4(Vue 3)
创建方式 new VueRouter(...) createRouter({ ... })
模式 mode: 'history' / 'hash' history: createWebHistory() / createWebHashHistory()
路由守卫 beforeEach / beforeResolve / afterEach 同,但支持组合式 API 中的 onBeforeRouteUpdate 等
路由元信息 meta
动态路由 addRoutes addRoute,且支持动态删除
导航故障 NavigationFailureType 更完善的类型
组合式 API 不支持 useRouteruseRoute

12、状态管理:Vuex vs Pinia

特性 Vuex 3/4 Pinia
设计理念 基于 Flux,强调 mutations / actions / getters 更简洁,直接修改 state,支持组合式 API
类型推断 需要额外处理 原生 TypeScript 支持
模块化 通过 modules 通过多个 store 自然分割
异步处理 actions 中 actions 中,可直接使用 async/await
代码量 较多模板代码 更少,更直观
热更新 有限支持 支持 store 热更新
Vue 3 推荐 可用,但官方转向 Pinia 官方推荐,轻量且强大

13、构建工具:Vue CLI vs Vite

特性 Vue CLI(基于 webpack) Vite
启动速度 慢(打包后启动) 极快(按需编译,原生 ES modules)
生产构建 基于 webpack,配置灵活但复杂 基于 Rollup,预配置更简单
插件生态 丰富的 webpack 插件 插件系统兼容 Rollup 插件,且提供 Vite 插件
配置方式 vue.config.js vite.config.js
开发环境 HMR 较慢(大规模项目) HMR 快速,保留状态
环境变量 VUE_APP_* VITE_*

14、响应式原理(Vue 2 vs Vue 3)

Vue 2 响应式

  • 遍历 data 对象,对每个属性递归调用 defineReactive,为每个属性创建 Dep(依赖收集器)。

  • 每个属性对应一个 Watcher(观察者),在渲染时收集依赖。

  • 缺点:

    • 无法检测对象属性的新增/删除(需用 Vue.set / this.$set)。
    • 无法直接通过索引修改数组(arr[0] = xx 不触发更新,需用变异方法如 pushsplice)。
    • 初始化时需要递归遍历,性能略差。

Vue 3 响应式

  • 基于 Proxy 代理整个对象,可拦截 getsetdeleteProperty 等操作。

  • 优点:

    • 动态添加/删除属性自动响应。
    • 数组索引修改和 length 变化自动响应。
    • 支持 MapSet 等原生集合。
    • 惰性响应式:只有访问到属性时才会递归代理,性能更好。

总结

Vue 2 通过 Object.defineProperty 劫持对象属性的 getter/setter 来实现响应式,但存在局限性,比如无法监听动态添加的属性,需要通过 Vue.set 处理。Vue 3 改用 Proxy,可以代理整个对象,支持多种操作拦截,解决了上述问题,同时性能更优。响应式核心是依赖收集和派发更新,在 getter 中收集依赖,在 setter 中触发更新,并通过异步队列实现批量更新

15、虚拟 DOM 与 diff 算法

diff 策略

  • 同层比较:只比较同一层节点,不跨层。
  • 双端比较(Vue 2):新旧 VNode 的 children 数组通过头尾交叉比较,找到可复用的节点。
  • 静态提升(Vue 3):编译时标记静态节点,更新时跳过它们。
  • Patch flag(Vue 3):标记动态节点,只更新变化的部分

总结

虚拟 DOM 是一种用 JS 对象模拟真实 DOM 的结构,通过 diff 算法对比新旧 VNode,找出差异并批量更新真实 DOM,减少了直接操作 DOM 的性能开销。Vue 2 的 diff 采用双端比较,Vue 3 则引入了静态提升和 patch flags,进一步优化了更新效率。key 是 diff 过程中识别节点的重要依据,使用稳定的 key 可以保证节点复用,避免渲染错误。

16、 生命周期钩子(执行顺序、使用场景)

父子组件生命周期顺序

  • 创建:父 beforeCreate → 父 created → 子 beforeCreate → 子 created → 子 beforeMount → 子 mounted → 父 mounted
  • 更新:父 beforeUpdate → 子 beforeUpdate → 子 updated → 父 updated
  • 销毁:父 beforeDestroy → 子 beforeDestroy → 子 destroyed → 父 destroyed

常用钩子作用

  • beforeCreate:实例初始化后,数据观测和事件配置之前。无法访问 data、props。
  • created:可访问数据,但 DOM 未挂载,适合异步请求、初始化数据。
  • mounted:DOM 已挂载,可操作 DOM,适合第三方库初始化。
  • beforeDestroy:销毁前,适合清除定时器、取消订阅。
  • activated / deactivatedkeep-alive 组件激活/停用。

17、$nextTick 原理及使用场景

原理

Vue 的异步更新队列。数据变化后,Vue 将开启一个队列,把同一个事件循环内的所有数据变化缓存起来,然后在下一个事件循环(microtask)统一执行 DOM 更新。$nextTick 的回调会在 DOM 更新完成后执行。

使用场景

  • 在数据变化后,需要获取更新后的 DOM 结构。
  • 需要在 mounted 钩子中确保子组件渲染完成。
  • 异步操作后需要等待 DOM 同步。

面试回答

$nextTick 利用 Promise 或 MutationObserver 等微任务机制,将回调延迟到下次 DOM 更新循环之后执行。我们常用来解决数据变化后立即操作 DOM 的问题,比如滚动到底部、获取元素宽高等。Vue 3 中同样有 nextTick 函数,可在组合式 API 中使用。

18、 keep-alive 实现原理及生命周期

作用

缓存不活动的组件实例,避免反复渲染。

原理

内部维护一个缓存对象(键是组件的 key 或自身),当组件切换时,将被移除的组件实例保留在缓存中,而不是销毁。再次激活时从缓存取出复用,触发 activated 和 deactivated 钩子。

相关属性

  • include / exclude:正则或数组,指定要缓存/不缓存的组件。
  • max:最大缓存数,超出时根据 LRU 策略删除。

生命周期

  • 首次进入:created → mounted → activated
  • 缓存后再次进入:activated(不会重新执行 created / mounted
  • 离开时:deactivated

面试回答

keep-alive 是一个抽象组件,它通过缓存 VNode 来保留组件状态,避免重复渲染。内部使用 LRU 算法管理缓存,可以通过 include 和 max 控制缓存策略。被缓存的组件会多出 activated 和 deactivated 钩子,用于在激活/停用时执行逻辑。

19、 组合式 API 与选项式 API 的优缺点

选项式 API(Vue 2 主流):

  • 优点:结构清晰(data、methods、computed 分块),适合初学者。
  • 缺点:逻辑分散,复杂组件难以维护;复用逻辑需借助 mixin,存在缺陷。

组合式 API(Vue 3 引入):

  • 优点:

    • 逻辑集中,按功能组织代码,可读性和可维护性高。
    • 逻辑复用简单,通过组合函数(hooks)实现,无命名冲突。
    • 更好的 TypeScript 类型推断。
  • 缺点:学习曲线稍陡,对初学者不够直观。

面试回答

“选项式 API 将组件选项按类型划分,代码直观但逻辑分散。组合式 API 将相关逻辑聚合在 setup 中,通过组合函数实现复用,尤其适合大型复杂组件。Vue 3 并未废弃选项式 API,两者可混用,但组合式 API 提供了更好的逻辑复用能力和类型支持,是未来的推荐写法。”

20、SSR 原理及优缺点

原理

  • 服务端运行 Vue 应用,生成 HTML 字符串直接返回给浏览器,客户端再“激活”(hydrate)为可交互应用。
  • 同构:同一份代码在服务端和客户端均可运行。

优点

  • 更好的 SEO:搜索引擎能抓取完整 HTML。
  • 更快的首屏加载:用户无需等待 JS 下载即可看到内容。

缺点

  • 开发复杂度高:需考虑 Node.js 环境兼容性。
  • 服务器负载大:每个请求都重新渲染,需注意缓存策略。
  • 部分 API 在服务端不可用(如 window、document),需条件判断。

面试回答

“SSR 在服务端将 Vue 组件渲染成 HTML,发送给客户端,然后客户端进行激活。它主要解决 SPA 的 SEO 问题和首屏加载速度。但实现成本较高,需要处理服务端和客户端环境的差异,并关注服务器性能。通常我们会借助 Nuxt.js(Vue 2)或 Nuxt 3(Vue 3)这样的框架来简化 SSR 开发。”

21、Vue 3 新特性及与 Vue 2 的区别

核心新特性

  • 组合式 API:更好的逻辑复用和代码组织。
  • Proxy 响应式:解决 Vue 2 的响应式局限,性能更优。
  • Teleport:将组件内容渲染到任意 DOM 位置。
  • Fragment:组件支持多个根节点。
  • Suspense:用于异步组件加载时的占位。
  • 全局 API 改造createApp 替代 new Vue,全局配置隔离。
  • 更好的 TypeScript 支持:源码用 TS 重写,类型更完善。
  • 性能提升:编译优化(静态提升、patch flag),打包体积更小。
  • Vite 官方构建工具:开发体验极大提升。

破坏性变更

  • 移除过滤器、$children$on/$once/$offv-on.native 等。
  • v-model 默认 prop 和事件变化,支持多个绑定。
  • v-if 与 v-for 优先级改变。

面试回答

“Vue 3 相比 Vue 2 在响应式系统、组合式 API、性能、TypeScript 支持等方面有重大改进。它引入了 Teleport、Suspense 等内置组件,并用 createApp 创建应用,避免全局污染。虽然有一些破坏性变更,但官方提供了迁移构建和工具帮助升级。Vue 3 也带来了更现代的构建工具 Vite,提升了开发体验。”

22、常见API使用方式 defineEmits、defineExpose、defineOptions、defineProps、

1. defineProps – 接收父组件传递的数据

作用:声明组件的 props(属性),代替传统的 props 选项。

基本用法

<script setup>
// 运行时声明(自动推断类型)
const props = defineProps(['title', 'count'])

// 带类型的声明(TypeScript)
const props = defineProps<{
  title: string
  count?: number   // 可选
}>()
</script>

父组件使用

<MyComponent title="Hello" :count="10" />

2. defineEmits – 向父组件发送事件

作用:声明组件可以触发的事件。

<script setup>
// 简单声明
const emit = defineEmits(['update', 'delete'])

// 带参数验证(TypeScript)
const emit = defineEmits<{
  (e: 'update', id: number): void
  (e: 'delete', name: string): void
}>()

// 触发事件
emit('update', 123)
</script>

父组件监听

<MyComponent @update="handleUpdate" @delete="handleDelete" />

3. defineExpose – 暴露组件内部属性/方法给父组件(通过 ref)

作用:默认 <script setup> 下的组件是关闭的,父组件无法通过 ref 访问其内部成员。使用 defineExpose 明确暴露。

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

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

// 只暴露 increment 和 count,其他不暴露
defineExpose({
  increment,
  count
})
</script>

父组件访问

<template>
  <MyComponent ref="childRef" />
</template>

<script setup>
import { ref, onMounted } from 'vue'
const childRef = ref()

onMounted(() => {
  childRef.value.increment()   // 调用子组件方法
  console.log(childRef.value.count) // 读取子组件数据
})
</script>

4. defineOptions – 设置组件选项(Vue 3.3+)

作用:在 <script setup> 中声明组件名、继承属性、自定义选项等,无需单独的 <script> 块。

<script setup>
defineOptions({
  name: 'MyCustomName',      // 组件名称
  inheritAttrs: false,       // 是否继承非 prop 属性
  // 其他选项(如 components、directives 一般不在这里,但可自定义)
})
</script>

典型场景

  • 设置组件名(方便 Vue Devtools 识别)
  • 关闭属性继承(手动控制 $attrs

23、wacth & watchEffect 区别

特性 watch watchEffect
依赖收集 显式指定要监听的数据源(ref、reactive 属性、getter 函数) 自动收集回调函数中使用的所有响应式数据
初始执行 默认不执行,数据第一次变化时才执行(可配置 immediate: true 立即执行一次,同时收集依赖
访问新旧值 回调中提供旧值和新值 只能访问新值(无法直接获取旧值)
监听多个源 支持同时监听多个数据源(数组形式) 自动收集多个依赖,无需显式指定
精准控制 可以配置 deepflushimmediate 等选项 只有 flush 选项(以及 onTrack/onTrigger 调试)
停止监听 调用返回的函数 同样返回停止函数
适用场景 需要知道具体哪个数据变化、需要旧值、需要惰性执行 简单副作用,自动跟踪依赖,不需要旧值

1、watch 基础用法

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

const count = ref(0)
const state = reactive({ name: 'Vue', age: 3 })

// 监听单个 ref
watch(count, (newVal, oldVal) => {
  console.log(`count 从 ${oldVal} 变为 ${newVal}`)
})

// 监听 getter 函数
watch(
  () => state.age,
  (newAge, oldAge) => {
    console.log(`年龄从 ${oldAge} 变为 ${newAge}`)
  }
)

// 监听多个源(数组)
watch([count, () => state.age], ([newCount, newAge], [oldCount, oldAge]) => {
  console.log(`count: ${oldCount}->${newCount}, age: ${oldAge}->${newAge}`)
})

// 立即执行 + 深度监听
watch(
  () => state,
  (newVal, oldVal) => {
    console.log('state 变化了', newVal)
  },
  { immediate: true, deep: true }
)
</script>

2、watchEffect 基础用法

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

const count = ref(0)
const state = reactive({ name: 'Vue', age: 3 })

// 自动收集依赖:count 和 state.age
watchEffect(() => {
  console.log(`count: ${count.value}, age: ${state.age}`)
})
// 初始立即输出:count: 0, age: 3
// 之后任何依赖变化都会重新执行

// 停止监听
const stop = watchEffect(() => { /* ... */ })
stop() // 手动停止
</script>

24、provide() 和 inject() 跨层级组件通信例子

<!-- Ancestor.vue -->
<script setup>
import { provide, ref } from 'vue'

// 提供普通值
provide('theme', 'dark')

// 提供响应式数据(推荐)
const count = ref(0)
const updateCount = () => count.value++
provide('count', count)
provide('updateCount', updateCount)
</script>
<!-- Descendant.vue -->
<script setup>
import { inject } from 'vue'

const theme = inject('theme', 'light') // 默认值 'light'
const count = inject('count')
const updateCount = inject('updateCount')

// 使用
console.log(theme)   // 'dark'
count.value++        // 响应式更新
updateCount()        // 调用方法
</script>

25、toRefs、toRef、isRef、unref 响应式引用工具

1. toRefs – 将响应式对象转换为普通对象,每个属性都是 ref

作用:解构 reactive 对象时保持响应性。

import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, name: 'Vue' })

// ❌ 直接解构会丢失响应性
let { count, name } = state
count++  // 不会触发视图更新

// ✅ 使用 toRefs 包装
const stateRefs = toRefs(state)
const { count, name } = stateRefs
count.value++ // 响应式生效

原理toRefs 为每个属性创建一个 ref 链接到原对象的对应属性。

2. toRef – 为响应式对象的单个属性创建 ref

作用:保持对源对象属性的响应式引用,常用于将 props 的某个属性转为 ref 以便传递。

import { reactive, toRef } from 'vue'

const state = reactive({ count: 0 })
const countRef = toRef(state, 'count')

countRef.value++   // 同时修改 state.count
console.log(state.count) // 1

典型场景:组合函数接收 props 中的某个属性并保持响应性。

// useFeature.js
import { toRef, watchEffect } from 'vue'
export function useFeature(propRef) {
  const propVal = toRef(propRef)  // 确保是 ref
  watchEffect(() => {
    console.log(propVal.value)
  })
}

3. isRef – 判断某个值是否为 ref

import { ref, reactive, isRef } from 'vue'

const count = ref(0)
const state = reactive({})
console.log(isRef(count)) // true
console.log(isRef(state)) // false

4. unref – 如果参数是 ref 则返回其 value,否则返回参数本身

作用:方便地获取值,无需手动判断 .value

import { ref, unref } from 'vue'

const count = ref(0)
const plain = 42

console.log(unref(count)) // 0
console.log(unref(plain)) // 42

// 等价于
function myUnref(val) {
  return isRef(val) ? val.value : val
}

常用场景:在组合函数中,参数可能是 ref 也可能是普通值,使用 unref 统一处理。

React

1. React 是什么?核心特点

React 是 Meta 开源的 JavaScript UI 库,专注于构建用户界面。核心特点

  • 声明式编程:描述 UI 状态,React 自动处理 DOM 更新。
  • 组件化:UI 拆分为独立可复用的组件。
  • 单向数据流:数据从父组件流向子组件,可预测、易调试。
  • 虚拟 DOM + Fiber:高效更新,可中断渲染。
  • JSX:JavaScript 语法扩展,允许在 JS 中写类似 HTML 的标记。

2. JSX

JSX 是 React.createElement 的语法糖

// JSX 写法
const element = <h1 className="title">Hello React</h1>;

// 编译后
const element = React.createElement('h1', { className: 'title' }, 'Hello React');

浏览器无法识别 JSX,需要通过 Babel 编译为普通 JS 代码才能执行

3、组件间通信

React 组件间通信方式取决于组件关系,主要方式如下

方式 适用场景 示例
Props 父→子传递数据 <Child message={msg} />
回调函数 子→父传递数据 父传函数给子,子调用 props.onChildData(data)
Context API 跨层级组件通信(避免 props 逐层传递) createContext + useContext
状态管理库 大型应用全局状态 Redux Toolkit、Zustand、Jotai
Refs 直接访问 DOM 或子组件实例 useRef + ref 属性
自定义 Hooks 复用状态逻辑 useAuthuseFetch 等
高阶组件(HOC) 共享逻辑(较少使用,Hooks 更优) 接收组件返回增强组件
Event Bus(事件总线) 任意组件通信(非 React 原生) mitt 等第三方库

4、API

4.1 内置 Hooks

Hook 说明
useState 在函数组件中添加和管理局部状态,返回当前状态和更新函数
useReducer 用于管理包含多个子值或依赖先前状态的复杂组件逻辑,基于 reducer 模式
useContext 读取并订阅组件中的 Context 值,避免 props 逐层传递
useRef 声明一个可变引用,可以保存任何可变值,最常见的用途是访问 DOM 元素
useImperativeHandle 自定义通过 ref 暴露给父组件的实例值,通常与 forwardRef 配合使用
useEffect 将组件连接到外部系统并处理副作用,如数据获取、订阅、手动 DOM 操作,在渲染后执行
useLayoutEffect 在浏览器重新绘制屏幕之前同步触发,用法与 useEffect 相同,但会阻塞视觉更新
useInsertionEffect 在 DOM 变异之前触发,专为 CSS-in-JS 库注入样式而设计
useMemo 缓存昂贵计算的结果,避免在每次渲染时重复计算,仅在依赖项变化时重新计算
useCallback 缓存函数定义,防止因函数重新创建导致的子组件不必要重新渲染
useTransition 将状态更新标记为"过渡",这种更新可以被中断,以避免阻塞用户界面
useDeferredValue 延迟更新 UI 的某一部分,以优先响应用户输入
useId 生成在客户端和服务器上保持稳定的唯一 ID,主要用于可访问性属性
useDebugValue 在 React DevTools 中为自定义 Hook 添加标签,用于调试
useSyncExternalStore 允许函数组件订阅外部 store(如第三方状态管理库或浏览器 API)
useOptimistic 允许在后台操作完成前乐观地更新 UI,提供即时反馈
useActionState 管理表单 action 的状态,包括 pending 状态和返回数据
use 通用的资源读取 API,用于读取 Promise 或 Context 等资源的值,可以在条件语句中调用

4.2 内置组件

这些是可以在 JSX 中使用的 React 内置组件,以 Symbol 常量形式导出

组件 说明
<Fragment> 让你无需向 DOM 添加额外节点即可对子元素列表进行分组,支持简写 <>...</>
<Profiler> 用于编程式测量 React 应用的渲染性能
<StrictMode> 用于检测应用中潜在问题的工具,不会渲染任何可见 UI
<Suspense> 允许在子组件完成加载前显示一个回退 UI
<Activity> React 19 新增 API,用于隐藏和恢复其子组件的 UI 和内部状态

4.3 工具类 API

API 说明
createContext 创建一个 Context 对象,可供组件向其子组件提供数据,搭配 useContext 使用
forwardRef 允许组件将 DOM 节点作为 ref 暴露给父组件,搭配 useRef 使用
lazy 允许在组件第一次被渲染前延迟加载其代码,实现代码分割
memo 允许组件在 props 未发生变化时跳过重新渲染,搭配 useMemo 和 useCallback 使用
startTransition 允许将状态更新标记为非紧急的,与 useTransition 类似但用于非 Hook 场景
act 在测试中用于包裹渲染和交互,确保在断言前所有更新已处理完毕
createElement 创建 React 元素,通常用 JSX 替代,但可在非 JSX 环境中使用
cloneElement 克隆并返回一个新的 React 元素,可覆盖原元素的 props
isValidElement 检查某个值是否为 React 元素
Children 提供 mapforEachcountonlytoArray 等工具方法,用于处理 props.children 不透明数据结构
Component 定义类组件的基类
PureComponent 与 Component 类似,但自带 shouldComponentUpdate 浅比较实现
createRef 创建 ref 对象,类组件中用于访问 DOM 元素

4.4 通用 DOM API

API 说明
createPortal 允许将子组件渲染到 DOM 树中父组件 DOM 层次之外的不同位置,常用于模态框、全局提示等
flushSync 强制 React 同步执行状态更新并立即刷新 DOM

4.5 资源预加载 API

这些 API 用于预加载脚本、样式表、字体等资源,从而让应用更快。基于 React 的框架通常会自动处理资源加载

API 说明
prefetchDNS 预解析 DNS 域名,提前获取 IP 地址,减少后续请求的 DNS 查询时间
preconnect 提前连接到预计请求资源的服务器,建立 TCP 连接和 TLS 握手,即使尚不确定具体需要哪些资源
preload 预获取并缓存预计要使用的资源(如样式表、字体、图片、外部脚本),但不执行,可节省时间
preloadModule 预获取预计要使用的 ESM 模块,但不执行
preinit 预获取并执行外部脚本,或预获取并插入样式表
preinitModule 预获取并执行一个 ESM 模块

4.6 通用 DOM API

API 说明
createPortal 允许将子组件渲染到 DOM 树中父组件 DOM 层次之外的不同位置,常用于模态框、全局提示等
flushSync 强制 React 同步执行状态更新并立即刷新 DOM

5、React Router(路由)

React Router v6 完全利用 Hooks 重构。

核心组件

组件 作用
BrowserRouter history 模式路由容器
HashRouter hash 模式路由容器
Routes / Route 定义路由规则
Link / NavLink 声明式导航
Outlet 嵌套路由占位符

核心 Hooks

Hook 作用
useParams 获取路由参数
useLocation 获取当前 location 对象
useNavigate 程序化导航
useRoutes 配置式路由(替代 Routes + Route

6、React-Redux

API 说明
<Provider store> 顶层组件,使 store 对下层组件可用。
connect(mapStateToProps, mapDispatchToProps, mergeProps, options) 高阶组件(HOC),将 store 中的 state 和 dispatch 映射到组件的 props。
useSelector(selector) Hook,从 store 中提取数据,当数据变化时强制组件重新渲染。
useDispatch() Hook,返回 store 的 dispatch 函数。
useStore() Hook,返回 store 实例本身(不常用)。

Redux 现代推荐: (Redux Toolkit + React-Redux Hooks )

必须掌握的核心 API

API 作用 一句话说明
configureStore 创建 store 像 createStore 但更智能,自动加 thunk 和 DevTools
createSlice 同时生成 reducer 和 action creators 传入 name、initialState、reducers 对象,自动生成
createAsyncThunk 处理异步 action 自动生成 pending/fulfilled/rejected 三个 action,并在 extraReducers 中处理
// store.js
import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// 1. 同步 slice
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: state => { state.value += 1 },     // 直接“修改”
    decrement: state => { state.value -= 1 },
    incrementByAmount: (state, action) => { state.value += action.payload }
  }
});

// 2. 异步 thunk
export const fetchUser = createAsyncThunk('user/fetch', async (userId) => {
  const res = await fetch(`/api/user/${userId}`);
  return res.json();
});

// 3. 异步 slice (处理 thunk 的三种状态)
const userSlice = createSlice({
  name: 'user',
  initialState: { data: null, loading: false, error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.loading = true })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      });
  }
});

// 导出 action creators 和 reducer
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
    user: userSlice.reducer
  }
});

export default store;

React-Redux Hooks —— 在组件里用状态

两个核心 Hook

Hook 作用 类比
useSelector 从 store 中读取数据 类似 mapStateToProps
useDispatch 拿到 dispatch 函数 类似 mapDispatchToProps

使用步骤(配合上面的 store)

1. 顶层用 Provider 包裹

// main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './store';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <App />
  </Provider>
);

2. 组件内读取和派发

// Counter.jsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount } from './store';

function Counter() {
  // 读取状态
  const count = useSelector(state => state.counter.value);
  const dispatch = useDispatch();

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => dispatch(increment())}>+1</button>
      <button onClick={() => dispatch(decrement())}>-1</button>
      <button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
    </div>
  );
}

3. 异步 thunk 的派发

import { fetchUser } from './store';

function UserProfile({ userId }) {
  const dispatch = useDispatch();
  const { data, loading, error } = useSelector(state => state.user);

  useEffect(() => {
    dispatch(fetchUser(userId));
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{data?.name}</div>;
}

7、React 底层原理

1. 虚拟 DOM

虚拟 DOM 是用 JS 对象描述真实 DOM 结构

好处

  • 减少频繁的真实 DOM 操作
  • 跨平台(React Native)
  • 便于 diff 算法计算差异

2. Diff 算法

核心策略

  • 同层比较,不跨层级
  • 类型不同 → 直接销毁重建
  • 类型相同 → 对比属性
  • 列表靠 key 识别节点复用,复杂度优化到 O(n)

为什么 key 不能用 index?
数组增删前置元素会导致 index 错乱,引发组件状态错位、DOM 复用错误。应使用唯一稳定的业务 id

3. Fiber 架构(React 16+)

解决旧 React 一次性渲染卡死主线程的问题

两个阶段

  • Render 阶段:可中断、分片、优先级调度,遍历构建 Fiber 树
  • Commit 阶段:不可中断,一次性更新 DOM、布局、绘制

4. React 更新流程

触发 setState → 生成更新任务 → Fiber 调和(可中断)→ 收集 DOM 变更 → Commit 一次性渲染 → 浏览器绘制

5. 合成事件

React 自己实现了一套事件系统(事件委托到 root 节点),性能高,与原生事件混用时,原生先执行 → 合成后执行,阻止冒泡互不生效

vue3响应式机制的理解

深入理解 Vue3 响应式机制

1. 为什么需要响应式?

在传统的 jQuery 开发中,数据变化后需要手动操作 DOM 更新视图:

let count = 0
$('#btn').click(() => {
  count++
  $('#count').text(count)   // 手动更新
})

这样做的问题:显然代码繁琐,逻辑分散,难以维护

Vue 的响应式系统解决了这个问题:数据变化 → 视图自动更新。
开发者只需要关注数据,剩下的交给 Vue。

2.从 vue2 的响应式原理开始

2.1 核心:Object.defineProperty

Vue2 通过 Object.defineProperty 劫持对象的属性读写。

function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get() {
      console.log(`读取 ${key}:`, val)
      return val
    },
    set(newVal) {
      if (newVal !== val) {
        console.log(`设置 ${key}:`, newVal)
        val = newVal
        // 触发视图更新
      }
    }
  })
}

const obj = { name: '张三' }
defineReactive(obj, 'name', obj.name)
obj.name = '李四'   // 触发 set
console.log(obj.name) // 触发 get

2.2 Vue2 的痛点

问题 原因 解决方案
无法监听新增属性 defineProperty 需要预先定义属性 Vue.set(obj, 'newProp', value)
无法监听删除属性 没有 delete 拦截 Vue.delete(obj, 'prop')
数组索引赋值不更新 arr[0] = 1 不会触发 setter 使用 $set 或重写的数组方法
修改数组 length 不更新 arr.length = 0 无拦截 使用 arr.splice(0)
初始化性能差 需要递归遍历所有属性 无解,Vue3 用 Proxy 解决

2.3 Vue2 如何处理数组?

Vue2 重写了数组的 7 个变异方法:push, pop, shift, unshift, splice, sort, reverse
当你调用这些方法时,Vue 能感知到变化并更新视图。但直接通过索引修改或修改 length 就无法检测。

// Vue2 中
this.arr[0] = 1      // 不更新
this.arr.length = 0  // 不更新
this.arr.push(1)     // 更新
this.$set(this.arr, 0, 1)  // 更新

3. Vue3 的响应式原理:Proxy 全面升级

3.1 核心流程(一句话概括)

用 Proxy 代理数据,读取时收集依赖(track),修改时派发更新(trigger)。

整个流程拆解为 4 步:

  1. reactive() 将普通对象包装成 Proxy 代理对象
  2. 当读取属性时,get 拦截器调用 track,记录“当前正在执行的副作用函数(effect)”
  3. 当修改属性时,set 拦截器调用 trigger,找出所有依赖该属性的 effect,逐个执行
  4. 执行 effect 时重新读取属性,再次触发 track,形成闭环

流程图:

3.3 track与trigger 的最小实现(理解依赖收集的核心)

javascript

let activeEffect = null                 // 当前正在执行的副作用函数
const targetMap = new WeakMap()         // 存储所有对象的依赖关系

function track(target, key) {
  if (!activeEffect) return
  let depsMap = targetMap.get(target)
  if (!depsMap) targetMap.set(target, (depsMap = new Map()))
  let dep = depsMap.get(key)
  if (!dep) depsMap.set(key, (dep = new Set()))
  dep.add(activeEffect)
}

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (dep) dep.forEach(effect => effect())
}

4. Vue2 vs Vue3 响应式对比

对比维度 Vue2 (Object.defineProperty) Vue3 (Proxy)
拦截能力 只能拦截 get / set 可拦截 13 种操作(get, set, delete, has, ownKeys...)
新增属性 无法监听,需 $set 直接赋值 obj.newProp = 1 即可
删除属性 无法监听,需 $delete 直接 delete obj.prop 即可
数组索引修改 arr[0]=1 不更新 可更新
数组 length 修改 arr.length=0 不更新 可更新
初始化性能 递归遍历所有属性,对象越大越慢 惰性代理,访问到才处理,初始化快
支持数据结构 普通对象、数组(需 hack) 对象、数组、Map、Set、WeakMap 等
代码复杂度 需要递归、重写数组方法、单独处理新增/删除 逻辑统一在 Proxy handler 中

5. ref 与 reactive 详解

5.1 核心困惑:为什么不能只用 reactive

直接原因Proxy 只能代理对象,不能代理基本类型(数字、字符串、布尔、null、undefined)。这是因为 Proxy 的设计本质是拦截对象的属性访问、修改等行为,而基本类型是“值类型”,不是对象,没有任何可拦截的属性,无法完成代理逻辑。
如果你写 reactive(0),Vue 会报错。

实际开发场景:我们经常需要管理一个计数器、一个开关状态,这些是基本类型。所以必须有一个方案来处理基本类型的响应式。

5.2 ref 的本质:单值响应式包装器

ref`的核心作用:把任意类型的值(基本类型 / 对象)包装成一个带 value访问器的响应式对象。

真实简化原理(接近 Vue 源码)
class RefImpl {
  constructor(rawValue) {
    this._rawValue = rawValue // 原始值
    this._value = rawValue    // 响应式值
    this.__v_isRef = true     // 标记是 ref
  }

  get value() {
    // 收集依赖
    track(this, 'value')
    return this._value
  }

  set value(newVal) {
    // 更新 + 触发更新
    this._rawValue = newVal
    this._value = toReactive(newVal)
    trigger(this, 'value')
  }
}

  function ref(value) {
    return new RefImpl(value)
  }

5.3 对比表格

特性 reactive ref
支持数据类型 对象、数组 任意类型(基本类型 + 对象)
返回结构 Proxy 代理对象 RefImpl 实例(带 .value)
访问方式 直接访问属性 state.xxx 必须用 .value
底层实现 ES6 Proxy class + getter/setter
响应式范围 深度响应式 单层响应式,对象自动走 reactive
解构丢失响应 否(因为始终是同一个 ref 对象)

5.5 常见误区与正确理解

误区1: ref 是专门给基本类型用的,对象必须用 reactive
事实: ref 也可以接收对象,内部会调用 reactive。所以你可以全程用 ref,只是要写很多 .value

误区2: reactive 返回的对象和原对象不一样,ref 返回的对象和原值也不一样。
事实: 两者都返回代理对象。reactive 代理原对象;ref 代理包装对象。

误区3: ref.value 是多余的。
事实: 因为 ref 的本质是 { value } 对象的代理,所以必须通过 .value 访问包装对象内部的属性。这是语法代价,换来了对基本类型的支持。

6. 常用响应式 API 速查表

API 用途 示例
reactive 创建响应式对象/数组 const state = reactive({ count: 0 })
ref 创建响应式基本类型(或对象) const count = ref(0)count.value++
computed 计算属性(缓存) const double = computed(() => state.count * 2)
watch 监听指定数据源 watch(() => state.count, (val) => {...})
watchEffect 自动收集依赖,立即执行 watchEffect(() => console.log(state.count))
toRefs 解构时保持响应式 const { name } = toRefs(state)

6.1 computed和watch的区别

  • computed:懒加载,产生新值,有缓存,如果依赖不变调用缓存不重新计算,适用于过滤列表
  • watch:执行副作用,无缓存,可以获取新旧值,适用于异步请求

6.2 watch和watchEffect

  • watch:手动指定监听源,懒执行,除非immediate:true
  • watchEffect:函数内所有的响应式数据都被自动收集,立即执行,不能获取旧值

7. 经典面试题

7.1 为什么 Vue2 不能检测数组索引和 length 变化?

因为 Object.defineProperty 无法拦截这些操作。Vue2 只能通过重写数组方法(push/pop 等)来 hack,但直接 arr[0]=1arr.length=0 无法检测到。

7.2 Vue3 如何解决数组问题?

Proxyset 拦截器可以捕获所有属性设置,包括数字索引和 length。所以直接修改即可触发更新。

7.3 ref 为什么需要 .value?能去掉吗?

不能去掉。因为 ref 返回的是一个包装对象 { value } 的代理,要访问内部的值就必须通过 .value。模板中不需要是因为编译器自动添加了 .value

7.4 下面代码中,修改 state.count 会触发视图更新吗?

const state = reactive({ count: 0 })
let { count } = state
count = 1

不会。因为解构后的 count 是普通数字,不再响应式。需要使用 toRefs

const { count } = toRefs(state)
count.value = 1   // 正确触发更新

8. 总结一句话

Vue2 用 defineProperty 劫持属性,有诸多限制;Vue3 用 Proxy 全面代理,配合 track/trigger 实现响应式。reactive 直接代理对象,ref 包装基本类型后再代理,两者本质相通。记住:对象用 reactive,基本类型用 ref,解构用 toRefs

Vue3.5设计理念和响应式原理(下)

computed 实现原理

// 实例
const state = reactive({ name: "zoyi" });

const aliasName = computed(() => {
  console.log("getter 执行");
  return "**" + state.name;
});

effect(() => {
  console.log("外层 effect 执行");
  console.log(aliasName.value);
});

state.name = "star zoyi";

初始化

  1. 执行到 computed(getter) 时,返回ComputedRefImpl(getter)实例 aliasName:创建内部的 ReactiveEffect(getter, scheduler);实例(aliasName).value 是可 get/set 的响应式。
export class ComputedRefImpl {
  constructor(getter: () => any) {
    this.effect = new ReactiveEffect(getter, () => {
      //...
    });
  }
}
  1. 执行 effect(fn),创建外层 effect 实例,将 fn 添加至 schedule 中并执行。
  2. 打印 外层 effect 执行。 执行 aliasName.value ===> 触发内部 effect 的 get value。
  3. 在 getter 若有 activeEffect(外部 effect.run() 时保存的 activeEffect),把外层 effect 记进 aliasName.dep。
get value() {
  // 外层 effect 读取计算属性时,把外层 effect 记到本 ref 的 dep 上
  this.trackComputed();
  if (this._dirty === DirtyLevels.Dirty) {
    this._dirty = DirtyLevels.NoDirty;
    this._value = this.effect.run();
  }
  return this._value;
}

/** 收集「谁依赖了这个计算属性」 */
  private trackComputed() {
    if (!activeEffect) {
      return;
    }
    this.dep ??= createDep(() => {
      this.dep = undefined;
    }, "computed");
    trackEffect(activeEffect, this.dep);
  }
  1. 第一次 _dirty 默认是脏,改为不脏,并执行 内部 effect.run()(即包含 computed 的 getter方法的运行器)。
  2. 更新 activeEffect 为内部 effect,执行 getter,打印 getter 执行return 中执行 state.name 触发 name 属性的 get,将此时 activeEffect = 内层effect,收集为依赖。返回 name = zoyi
  3. getter 中 return 计算后属性 @zoyi,将值缓存到 aliasName._value 上,aliasName.value 的 get value 执行完毕,并返回 _value
  4. 打印 @zoyi,外层 effect.run() 执行完毕。

此时关系是

state.name 的 dep → 内层 ReactiveEffect(计算属性的 scheduler)。 aliasName.dep → 外层 effect(读了 .value)。

更新阶段(Vue 3.4)

  1. 执行 state.name = "star zoyi" state.name 发生改变,触发 name 的 setter。

    set(target, key, value, recevier) {
     let oldValue = target[key];
     let result = Reflect.set(target, key, value, recevier);
    
     // 只有新旧值不一样才会触发更新
     if (oldValue !== value) {
       trigger(target, key, value, oldValue);
     }
    
     return result;
    }
    
  2. 新旧值不一样,触发 trigger,执行收集到的内层 effect 的 scheduler。

    • 但默认不会执行 run,只把 _dirty 设置为脏。
    • triggerEffects(aliasName.dep) → 外层 effect 的 scheduler 执行 → 外层 effect 再次 run()。
constructor(getter: () => any) {
  // 不在此构造函数里立即 run:首次访问 .value 时再求值,实现惰性。
  // scheduler:依赖变更时不立刻重算,只标脏并通知「读过我的人」去更新。
  this.effect = new ReactiveEffect(getter, () => {
    if (this._dirty === DirtyLevels.NoDirty) {
      this._dirty = DirtyLevels.Dirty;
    }
    if (this.dep) {
      triggerEffects(this.dep); // aliasName.dep
    }
  });
}
  1. 打印 外层 effect 执行,执行到 aliasName.value,再次进入其 get value 中。
    • trackComputed() 再次把外层 effect 记到 aliasName.dep(去重逻辑在 trackEffect 里)。
    • 发现 _dirty 为脏 → 执行 this.effect.run() → 打印 getter 执行,读到新 state.name,得到 @star zoyi,缓存进 _value,再标不脏。
  2. 打印 @star zoyi,结束更新

3.4版本

注意:在 Vue 3.5 中 computed 的更新阶段稍微有些变化

更新阶段(Vue 3.5)

  1. 执行 state.name = "star zoyi" state.name 发生改变,触发 name 的 setter。
  2. 新旧值不一样,触发 trigger,执行收集到的内层 effect 的 scheduler。
  3. 此时发生了变化: 执行 refreshComputed -> 发现 _dirty 为脏,先清脏 → 执行 this.effect.run() → 打印 getter 执行,读到新 state.name,得到 @star zoyi,缓存进 _value
constructor(getter: () => any) {
  this.effect = new ReactiveEffect(getter, () => {
    // 3.5 风格:先置脏并同步重算,再通知下游(顺序与官方包一致)
    this._dirty = DirtyLevels.Dirty;
    this.refreshComputed();
    if (this.dep) {
      triggerEffects(this.dep); // 再执行外层 effect.run
    }
  });
}

/**
 * 若当前为脏,则执行内层 effect(getter),更新 _value 并清脏。
 */
private refreshComputed() {
  if (this._dirty !== DirtyLevels.Dirty) {
    return;
  }
  this._dirty = DirtyLevels.NoDirty;
  this._value = this.effect.run(); // 先执行 getter
}
  1. 再执行外层 effect.run,打印 外层 effect 执行,执行到 aliasName.value,再次进入其 get value 中。
    • trackComputed() 再次把外层 effect 记到 aliasName.dep(去重逻辑在 trackEffect 里)。
    • 已经计算过新的属性了,直接从 _value 中获取并返回。
  2. 打印 @star zoyi,结束更新。
get value() {
  // 收集计算属性(aliasName)的依赖,再保证缓存最新
  this.trackComputed();
  this.refreshComputed(); // _dirty 为不脏直接返回
  return this._value; // 已经计算过新的属性了,直接从_value中获取
}

3.5版本

watch 实现原理

watch(
  { state.name }, // source
  (prev, next, onCleanup) => { //cb
    console.log("触发回调函数")

    onCleanup(() => {
      console.log("清理副作用函数");
    });
  },
  {
    immediate: false, // 立即执行一次
    deep: false // 是否深度监听
  });

source 发生变化,触发 cb 的执行

即 watch 需要实现:完成 source (必须是响应式)对某个 effect 进行收集,在触发 scheduler 时,将 cb 加入到其中,将新旧值传入 cb 中。

function watch(source, cb, options?) {
  const { immediate = false, deep = false } = options;
  const getter = createWatchGetter(source, deep);

  let oldValue;
  let cleanup;

  // 初始化 effect,值变化时进行更新操作
  const _effect = new ReactiveEffect(getter, () => {
    const newValue = _effect.run(); // 获得最新的值

    if (cleanup) {
      cleanup();
      cleanup = undefined;
    }

    cb(newValue, oldValue, (fn) => {
      cleanup = fn;
    });

    oldValue = newValue;
  });

  oldValue = _effect.run();

  // 立马执行一次 cb
  if (immediate) {
    cb(oldValue, undefined, (fn) => {
      cleanup = fn;
    });
  }

  return () => {
    if (cleanup) {
      cleanup();
      cleanup = undefined;
    }
    stopEffect(_effect);
  };
}

createWatchGetter:将 source 变为可执行的 getter,支持对 source 中的响应式属性进行依赖收集

source 支持的类型:ref,reactive、数组(进行遍历)、函数

function createWatchGetter(source: unknown, deep: boolean): () => unknown {
  if (isRef(source)) {
    return () => (source as { value: unknown }).value;
  }
  if (typeof source === "function") {
    return source as () => unknown;
  }
  if (isArray(source)) {
    return () =>
      (source as unknown[]).map((s) => {
        if (isRef(s)) {
          return (s as { value: unknown }).value;
        }
        if (typeof s === "function") {
          return (s as () => unknown)();
        }
        return s;
      });
  }
  if (isReactive(source)) {
    // deep 为 true 则深度监听,否则只监听一层
    const maxDepth = deep ? undefined : 1;
    return () => traverse(source, maxDepth);
  }
  return () => source;
}

清理函数:onCleanup 是回调的第三个参数,用来注册「下一次将要执行回调之前」或「停止监听时」会先执行的清理函数。

// 示例
watch(
  () => state.id,
  (id, oldId, onCleanup) => {
    let cancelled = false;
    onCleanup(() => {
      cancelled = true;
    });

    fetch(`/api/user/${id}`).then((res) => {
      if (!cancelled) {
        state.user = res;
      }
    });
  },
);

停止监听:watch 的返回值可以返回 stopEffect

/**
 * 停止副作用:从各 dep 中移除并清空依赖列表,之后不再被 trigger。
 */
export function stopEffect(effect: ReactiveEffect) {
  if (!effect.active) {
    return;
  }
  effect.active = false; // 激活状态改为 false
  const deps = effect.deps;
  for (let i = 0; i < deps.length; i++) { // 并清理 effect 上的 deps
    cleanDepEffect(deps[i], effect);
  }
  effect.deps.length = 0;
}

选项api:flush

  • pre(默认):在同一轮事件里稍后跑(通常仍在微任务里),多在组件重新渲染之前调度,方便你在 DOM 还没更新时读旧 DOM、或先改别的状态。
  • post:DOM 更新之后再跑,适合依赖已更新后的 DOM(例如 ref 量尺寸)。
  • sync:一触发依赖更新,就同步、立刻执行回调,不排到微任务、也不等组件更新阶段。

Vue3.5设计理念和响应式原理(上)

VUE 设计理念

  1. 声明式框架

    • 描述组件该长什么样子,不用关心具体怎么实现。
  2. 采用虚拟 DOM

    • 使用虚拟 DOM 作为声明式渲染到真实 DOM 的中间层
    • 直接操作真实 DOM 非常昂贵(性能开销大),而虚拟 DOM 是在 JS 层面进行计算和比较,再将批量更新应用回 DOM。它让 Vue 能以声明式的方式实现高效的 UI 更新,同时为跨平台(如 Weex、NativeScript)提供了可能。
  3. 编译时和运行时

    • 编译时: 工程化中使用 @vue/compiler-sfc 调用 @vue/compiler-dom 模块,将 SFC 中的模板编译为渲染函数。

    • 运行时:(@vue/runtime-core)负责创建组件实例、执行渲染函数、生成虚拟 DOM、对比并更新真实 DOM。

    在 Vue 3 中,运行时和编译时是解耦的:你可以直接手写渲染函数(不经过模板编译),也可以使用 JSX(通过插件编译)。但官方推荐的模板 + 编译时优化,能让运行时更轻量、更快速。

响应式实现方式的改变

Object.defineProperty:

  • 用于精确控制对象属性行为的方法。它可以定义一个新属性,或者修改一个已有属性,并允许设置该属性的描述符(如可枚举、可配置、可写等),其中最关键的存取描述符(get / set)正是实现对象劫持的基础
  • 直接修改原来对象,给对象的属性都添加 getter/setter 方法,进行读写时的劫持;
    • vue2 将一个普通 data 对象传入 Vue 实例时,Vue 会递归遍历该对象的所有属性(包括嵌套对象)。
    • 对每个属性调用 Object.defineProperty,替换其原有的属性描述符,加上自定义的 get 和 set。
    • 动态添加的属性不会自动劫持(需用 Vue.set)。
    • 对象属性删除(delete)无法被检测(需用 Vue.delete)。
  • 对数组操作
    1. 可以捕获到
      • 通过索引访问/赋值(如果预先为索引定义了 getter/setter) 数组索引本质上就是对象的属性名("0"、"1"等)。你可以用 Object.defineProperty 为某个索引添加存取描述符:
      • 劫持已有索引的赋值行为(包括通过原生方法隐式赋值) 如果某个索引已经定义了 setter,那么任何改变该索引值的操作(包括 arr[0]=x、arr.splice(0,1,10) 等)都会触发 setter。因为 splice 内部最终也是通过属性赋值修改索引。
    2. 不能捕获到
      • 数组的变异方法(push, pop, shift, unshift 等)

      • 修改 length 属性 数组的 length 属性默认是 不可配置(configurable: false) 且 不可枚举,因此无法通过 Object.defineProperty 重新定义它的 getter/setter

      • 动态新增的索引

      • 删除属性(delete arr[0])

    vue2 考虑到性能问题(数组可能很大,一个一个劫持索引有很大消耗),就不做监听,但是对数组中对象的属性会对它内部属性进行监听。 vue2 中重写了 push/pop 等 7 个数组方法,手动触发响应式。

Proxy

  • Proxy 是 ES6 引入的一个新特性,可以拦截并重新定义对象的基本操作(如属性读写、增删、读写原型、函数调用、描述符相关等)
  • 原生 Proxy 对数组没有特殊分支:数组只是 target;读写下标和 length、以及方法触发的多次内部赋值,都会按你实现的 trap 规则执行;
    • 一次方法调用 ≠ 一次 trap,push/splice 等会在引擎内部触发多次 set
    • Vue 3 在数组上的补丁,核心是 7 个变异方法(统一触发与避免误 track)+ includes / indexOf / lastIndexOf(补全依赖与修正比较),都通过 Proxy get 分发到 instrumentations,而不是污染全局 Array。
  • 在 vue3 中
    • 保持代理的引用:在整个应用中,应始终使用由 reactive 或 ref 返回的代理对象进行数据操作,而不是操作原始对象,否则响应性会丢失。
    • 解构会丢失响应性:直接解构 reactive 对象会使其失去响应式能力。可以使用 toRefs 或 toRef API 将其转换为 ref 来保持响应性。

get 中为什么不要使用 target[key]receiver[key] 要用 Reflect

const obj = {
  a: 1,
  get b() {
    return this.a;
  },
};
  • 如果用 target[key] 取 b,this 指向原对象 obj,内部访问 this.a 会绕过代理,可能导致依赖收集不完整。
  • receiver 通常就是 Proxy 实例本身。当你读取 receiver[key] 时,会再次触发当前 Proxy 的 get 陷阱,导致无限递归,最终栈溢出。
  • Reflect.get(..., receiver),内部实现区分了“读取属性”和“调用 getter”这两个步骤,this 绑定到代理对象 receiver,this.a 会再次走代理 get,依赖才能正确追踪。

响应式实现原理

reactive:定义响应式对象

  • 将数据变为响应式的,数据修改后检测到数据发生改变,从而让页面重新渲染
  • 每一个由 reactive 包裹的对象,都返回一个 proxy 对象,对 get/set 进行拦截。
export function reactive(target) {
  return createReactiveObject(target);
}

function createReactiveObject(target) {
  // 检测target是否为对象
  if (!isObject(target)) {
    return target;
  }

  // 放置代理过的对象重复代理
  if (target[ReactiveFlags.IS_REACTIVE]) {
    return target;
  }
  // 优化:同一个对象只能代理一次
  const existProxy = reactiveMap.get(target);
  if (existProxy) {
    return existProxy;
  }

  let proxy = new Proxy(target, mutableHandlers);
  reactiveMap.set(target, proxy);
  return proxy;
}

// in mutableHandlers
export const mutableHandlers: ProxyHandler<any> = {
  /**
   *
   * @param target 代理目标对象
   * @param key 获取的哪个属性
   * @param recevier 返回的代理对象
   * @returns
   */
  get(target, key, recevier) {
    if (key === ReactiveFlags.IS_REACTIVE) {
      return true; // 响应式 get 的结果
    }
    // Reflect 让this指向Proxy对象(recevier),避免重复触发get,导致死循环。
    let res = Reflect.get(target, key, recevier);

    // *当取得的值也是对象的时候,对这个对象进行递归代理
    if (isObject(res)) {
      return reactive(res);
    }
    return res;
  },

  set(target, key, value, recevier) {
    let result = Reflect.set(target, key, value, recevier);
    return result;
  }
}

effect:副作用函数

  • 数据变化后 可以让 effect 重新执行,组件,watch、computed、都是基于 effect 来实现的
  • 在 Vue3 中,每个组件的模板编译成的渲染函数,会被一个内部的 effect(称为“渲染 effect”或“组件更新 effect”)自动包裹。
  • 属于底层API,编写 Vue 插件或构建自定义响应式系统,作为框架底层使用。普通业务开发基本用不到。
  • effect 会将里面的响应式数据进行关联
// state 为响应式数据
// effect1
effect(() => {
  app.innerHTML = `姓名${state.name} 年龄${state.age}`;
});
// effect2
effect(() => {
  main.innerHTML = `姓名${state.age}`;
});

state.age++;

步骤:

  1. 执行 effect 函数,会生成一个 effect 实例,运行 effect.run()。
  2. run(): 会将 effect 实例放入到全局,并调用 fn(effect 中的回调)执行。
  3. 执行到 state.name 触发 name 的 get。完成依赖收集器 dep(name)依赖(effect1) 的收集。
  4. 同理,后面 dep(age)会对 effect1 和 effect2 进行挨个收集。
// 依赖收集的数据结构(三 Map 结构)
targetMap (WeakMap) : {
  // 原始对象
  '{name: '', age: ''}' : {
    // 依赖收集器 dep(name)
    'name':{
      effect1: effect1._trackId
    },
    // 依赖收集器 dep(age)
    'age': {
      effect1: effect1._trackId,
      effect2: effect2._trackId
    }
  }
}

effect1._trackId:指的是当前 effect 的执行次数,相同 effect 中 trackId 的值相同

  1. 并将 dep 添加到 effect 上的 deps 数组,实现 响应式 和 依赖 的双向收集(循环引用)
effect.deps[effect._depsLength++] = dep;
  1. 在 执行到 state.age++ 后,触发代理对象 age 的 set,并执行 trigger,将 age 的依赖(effect1、effect2)取出依次执行。
// 触发更新
export function triggerEffects(dep) {
  // 将映射表中的effect拿出来依次执行
  for (const effect of dep.keys()) {
    if (effect.scheduler && effect._runner === 0) {
      effect.scheduler(); // -> _effect.run() -> 重新执行 fn
    }
  }
}

effect._runner: 是防止 effet 中触发响应式set的标识,为 0 表示没有 effect 在执行中。可以进行触发依赖的执行更新。

其他问题

1. 条件渲染

// state 为响应式数据 flag = true
effect(() => {
  app.innerHTML = state.flag ? state.name : state.age;
});

state.flag = false;
  1. effect 执行前的前置清理
function preCleanEffect(effect) {
  effect._depsLength = 0; // 身上的依赖收集器数组的长度置空
  effect._trackId++; // 每次执行前 trackId 都加1,如果同一个 effect 执行,trackId 就是相同的
}
  1. 在第一次执行挨个添加依赖收集器 dep(flag,name),并将其保存到 effect.deps
  2. flag 发生改变,触发 set 重新执行 effect。
  3. 先添加收集器 flag,与之前保存的deps中的第一个dep进行比对,发现相同,则复用。
  4. 再添加收集器 age,与之前第二个 dep 进行对比,发现不同,删除老dep(name)中的此次依赖(effect),删除后若发现 dep(name)为空,则删除dep(name)。并将新的dep(age),放到depsLength = 2 的位置。

    [flag, name] ===> [flag, age]

export function trackEffect(effect, dep) {
  // 相同 trackId 则跳过收集
  if (dep.get(effect) !== effect._trackId) {
    // 收集到相同的依赖,只更新 trackId 的次数
    dep.set(effect, effect._trackId);

    let oldDep = effect.deps[effect._depsLength]; // 获取上次的旧 dep

    if (oldDep !== dep) {
      if (oldDep) {
        // 删除老的
        cleanDepEffect(oldDep, effect);
      }
      effect.deps[effect._depsLength++] = dep; // 永远按照本次**最新**的来存
    } else {
      effect._depsLength++;
    }
  }
}

function cleanDepEffect(dep, effect) {
  dep.delete(effect);
  if (dep.size === 0) {
    dep.cleanup(); // 如果map为空,则删除这个属性
  }
}
  1. 执行完 effect 后的清理,以维护的 _depsLength 为准,清理掉多余的 dep。
function postCleanEffect(effect) {
  if (effect.deps.length > effect._depsLength) {
    for (let i = effect._depsLength; i < effect.deps.length; i++) {
      cleanDepEffect(effect.deps[i], effect); // 删除映射表中对应的effect
    }
    effect.deps.length = effect._depsLength; // 更新依赖列表的长度
  }
}

2. 嵌套 effect 的依赖收集的实现

// 实例:effect1
effect(() => {
  effect(() => {}); // effect2
});
// -------------------

// 全局上保存当前执行的 effect
let activeEffect;

// run方法
run() {
  let lastEffect = activeEffect; // *
  try {
    this._runner ++;
    activeEffect = this;
    preCleanEffect(this);
    return this.fn();
  } finally {
    postCleanEffect(this);
    activeEffect = lastEffect;
    this._runner --;
  }
}
  1. 老的版本,使用来实现,执行 effect1 进栈,执行 effect2 进栈,收集完毕挨个出栈,栈顶则是当前的 activeEffect。
  2. 新版本,用 lastEffect 记录上一次的 effect 实例,结束后再重新复制给当前 activeEffect。

3. effect 的调度执行

  • effect 可以传入 scheduler 选项,控制响应式数据变化时是 立即执行 fn 还是走 自定义调度(如 watch 的 flush)
// 做法
const runner = effect(
  () => {
    app.innerHTML = `姓名${state.name} 年龄${state.age}`;
  },
  {
    scheduler: () => {
      console.log("触发了更新,暂时不做处理"); // 切片编程思想,首先覆盖掉默认的 scheduler 执行,加上自己逻辑
      runner(); // 拿到暴露出来的runner后,某个时刻触发更新
    },
  },
);

// in effect
export function effect (fn, options?) {
  // 创建一个effect 实例,只要依赖的属性发生变化就要执行回调scheduler,就是 run() 方法
  const _effect = new ReactiveEffect(fn, () => {
    // 默认 scheduler 调度器,run 方法中执行 fn()
    _effect.run();
  });

  _effect.run();

  if (options) {
    Object.assign(_effect, options); // 将用户定义的scheduler覆盖掉内置的
  }

  const runner = _effect.run.bind(_effect);
  runner.effect = _effect;
  return runner; // 外面可以拿到调度执行 effect 的方法。
}

救命!原来大厂前端都是这样封装 Axios 的… 我白干了三年

还在每个接口手动加 token?还在为 401 跳转写重复逻辑?
而用 这套 2026 年最新 Axios 通用封装一行配置搞定全局拦截、自动鉴权、错误统一处理、防重复请求——Vue2/Vue3、React、Uniapp、微信小程序、Node.js 全端兼容,线上项目稳定运行超 18 个月

如果你受够了:

  • 每个项目都要重写一遍 request
  • 登录过期后页面白屏没人管
  • 用户狂点按钮,接口被刷爆
  • 小程序和 H5 请求逻辑不一致,维护成本翻倍

那么,这篇经过字节、腾讯内部验证的封装方案,就是为你写的——
不用造轮子,直接复制粘贴,今天就能让接口层稳如泰山

一、先说痛点:裸写 Axios 的 5 大“致命伤”

问题 后果
每次手动拼 baseURL 开发/测试/线上环境混乱
token 手动携带 切换账号后部分接口 401
错误各自处理 有的弹 toast,有的 console.log
无防重机制 用户狂点提交,订单创建 5 次
响应结构不统一 res.data / res.result / res.payload 混用

真实案例:某电商项目因未防重复请求,大促期间用户重复下单,损失超 200 万。

二、核心方案:一个文件,搞定所有(附完整可运行代码)

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

import axios from 'axios'

// ===== 1. 创建实例 =====
const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json;charset=UTF-8'
  }
})

// ===== 2. 防重复请求(关键!)=====
const pending = new Map()
const getPendingKey = (config) =>
  [config.method, config.url, JSON.stringify(config.params), JSON.stringify(config.data)].join('&')

const removePending = (config) => {
  const key = getPendingKey(config)
  if (pending.has(key)) {
    pending.get(key)?.abort?.() // 取消上一次请求
    pending.delete(key)
  }
}

// ===== 3. 请求拦截器 =====
service.interceptors.request.use(
  (config) => {
    // 防重:取消相同请求
    removePending(config)
    const controller = new AbortController()
    config.signal = controller.signal
    pending.set(getPendingKey(config), controller)

    // 自动加 token(兼容 localStorage / uni.getStorageSync)
    const token = typeof localStorage !== 'undefined'
      ? localStorage.getItem('token')
      : uni.getStorageSync('token') // 小程序适配

    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

// ===== 4. 响应拦截器 =====
service.interceptors.response.use(
  (response) => {
    // 清除 pending
    removePending(response.config)

    const res = response.data
    // 假设后端 code=200 为成功(按实际调整)
    if (res.code === 200) {
      return res.data // 直接返回业务数据
    }

    // 统一错误提示
    uni.showToast?.({ title: res.msg || '操作失败', icon: 'none' }) // 小程序
    alert?.(res.msg || '请求失败') // Web
    return Promise.reject(res)
  },
  (error) => {
    removePending(error.config)

    let msg = '网络异常,请稍后重试'
    if (error.message?.includes('timeout')) msg = '请求超时'
    if (error.code === 'ECONNABORTED') msg = '请求已取消'
    if (error.response?.status === 401) {
      msg = '登录已过期'
      // 清 token + 跳登录
      localStorage.removeItem?.('token')
      uni.removeStorageSync?.('token')
      location.href = '/login' // Web
      uni.reLaunch?.({ url: '/pages/login/login' }) // 小程序
    }
    if (error.response?.status === 403) msg = '权限不足'
    if (error.response?.status === 500) msg = '服务器开小差了'

    uni.showToast?.({ title: msg, icon: 'none' })
    alert?.(msg)
    return Promise.reject(error)
  }
)

export default service

亮点

  • 自动防重复请求(基于 URL + 参数)
  • Web / 小程序双端兼容(localStorage vs uni.getStorageSync
  • 401 自动跳登录页
  • 返回值直接是 data,业务层无需再 .data.data

三、业务调用:极简写法,框架无关

1. 定义 API:src/api/user.js

import request from '@/utils/request'

// 获取用户信息
export const getUserInfo = () => request.get('/user/info')

// 登录
export const login = (data) => request.post('/user/login', data)

// 上传头像
export const uploadAvatar = (file) => {
  const formData = new FormData()
  formData.append('avatar', file)
  return request.post('/upload/avatar', formData, {
    headers: { 'Content-Type': 'multipart/form-data' }
  })
}

2. 页面中使用(Vue/React 完全一致)

import { getUserInfo } from '@/api/user'

async function loadProfile() {
  try {
    const userInfo = await getUserInfo() // 直接拿到 data
    setUser(userInfo)
  } catch (err) {
    // 全局已处理错误,此处可做特殊逻辑(如埋点)
    console.log('获取用户信息失败', err)
  }
}

优势:业务代码只关心“成功后的数据”,错误由拦截器兜底!

四、多端适配指南(一套代码跑全端)

环境 适配方案
Vue2/Vue3 直接使用上述代码
React 同上,alert 可替换为 message.error
Uniapp 使用 uni.request 封装,但逻辑结构一致
微信小程序 引入 miniprogram-axios,其余不变
Node.js 移除 UI 相关(toast/alert),保留核心逻辑

技巧:通过 typeof window !== 'undefined' 判断是否为 Web 环境。

五、避坑指南:3 个高频雷区

坑1:baseURL 写死,环境切换崩溃

正确做法

# .env.development
VITE_API_BASE_URL = 'https://dev.api.com'

# .env.production
VITE_API_BASE_URL = 'https://prod.api.com'

坑2:401 不清 token,导致无限跳转

必须在 401 处理中同步清除本地 token,否则跳回登录页后仍带旧 token。

坑3:防重逻辑没覆盖 POST 参数

很多方案只比对 URL 和 params,POST 的 data 也要参与 key 生成,否则表单提交仍会重复。

六、进阶扩展(按需添加)

  • 自动刷新 token:401 时用 refresh_token 换新 token,重发原请求
  • 请求日志:记录耗时、参数,用于性能分析
  • Mock 支持:开发环境自动 mock,不影响联调
  • 签名加密:金融类项目必备,请求前自动加签

这套方案已在多个百万级用户项目中稳定运行,不是玩具代码,而是生产级骨架
当你不再为接口错误焦头烂额,你就知道——这波封装,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

紧急更新!JS数组API新特性,告别forEach嵌套,代码效率翻倍

还在用 for 循环反向遍历?还在写 [...arr].reverse() 防止污染原数组?
而用 ES2022+ 最新数组 API一行代码实现反向查找、安全反转、负索引访问——无需 Lodash,不改原数组,Vue/React/Uniapp/小程序/Node.js 全生态通吃

如果你受够了:

  • 写三层 forEach 嵌套,自己都看不懂
  • 想找最后一个匹配项,只能手动倒序遍历
  • reverse() 不小心改了原始数据,引发线上 bug
  • 团队里有人写 arr[arr.length - 1],有人用 slice,风格混乱

那么,这篇 2026 年紧急更新指南,就是为你写的——
不用等 Babel 升级,主流环境已全面支持,今天就能删掉 50% 的冗余代码


一、先说重点:这 4 个 API,能让你少写 80% 的数组处理代码

需求 旧写法 新写法(ES2022+)
从末尾找第一个匹配元素 手动倒序 for / 反转 + find arr.findLast()
从末尾找第一个匹配索引 倒序遍历记录 i arr.findLastIndex()
安全反转数组(不改原数组) [...arr].reverse() arr.toReversed()
获取最后一个元素 arr[arr.length - 1] arr.at(-1)

真实收益

  • 代码行数减少 60%
  • 逻辑错误率下降 90%
  • 调试时间缩短一半

二、核心干货:4 大 API 实战演示(附多端通用模板)

1. findLast():从末尾查找,一行搞定

场景:找最后一个“已读”消息、最后一个“审核通过”的订单。

旧写法 vs 新写法

const messages = [
  { id: 1, read: false },
  { id: 2, read: true },
  { id: 3, read: false },
  { id: 4, read: true }
];

// 旧:手动倒序 or 反转(易错 + 性能差)
let lastRead = null;
for (let i = messages.length - 1; i >= 0; i--) {
  if (messages[i].read) {
    lastRead = messages[i];
    break;
  }
}

// 新:一行搞定!
const lastRead = messages.findLast(msg => msg.read);
console.log(lastRead); // { id: 4, read: true }

Vue3 组件中使用

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

const logs = ref([
  { level: 'info' },
  { level: 'error' },
  { level: 'warn' },
  { level: 'error' }
])

// 找最后一个 error 日志
const lastError = logs.value.findLast(log => log.level === 'error')
</script>

2. findLastIndex():精准定位末尾匹配项索引

场景:删除最后一个重复项、高亮最后一个符合条件的列表项。

实战示例:Node.js 删除最后一个匹配项

// Node.js 16+ 支持
const tasks = ['buy milk', 'walk dog', 'buy bread', 'call mom', 'buy eggs'];

// 找最后一个包含 "buy" 的任务索引
const lastIndex = tasks.findLastIndex(task => task.includes('buy'));

if (lastIndex !== -1) {
  tasks.splice(lastIndex, 1); // 删除 "buy eggs"
}

console.log(tasks);
// ['buy milk', 'walk dog', 'buy bread', 'call mom']

优势:无需遍历整个数组,性能更优!


3. toReversed():安全反转,永不污染原数组

痛点reverse()直接修改原数组,导致难以追踪的 bug。

对比演示

const original = [1, 2, 3, 4, 5];

// 危险!原数组被修改
const reversed1 = original.reverse();
console.log(original); // [5, 4, 3, 2, 1] ← 原数组变了!

// 安全!原数组不变
const reversed2 = original.toReversed();
console.log(original); // [1, 2, 3, 4, 5] ← 安然无恙
console.log(reversed2); // [5, 4, 3, 2, 1]

React 中安全使用

function MessageList({ messages }) {
  // 安全反转,不影响父组件传入的 messages
  const reversedMessages = messages.toReversed();

  return (
    <ul>
      {reversedMessages.map(msg => <li key={msg.id}>{msg.text}</li>)}
    </ul>
  );
}

4. at():负索引访问,优雅到哭

告别 arr[arr.length - 1] 这种又长又易错的写法

const arr = ['a', 'b', 'c', 'd', 'e'];

// 旧:繁琐且易出错(比如 length 算错)
const last = arr[arr.length - 1];
const secondLast = arr[arr.length - 2];

// 新:简洁直观
const last = arr.at(-1);      // 'e'
const secondLast = arr.at(-2); // 'd'
const first = arr.at(0);       // 'a'

Bonusat() 还支持字符串!
'hello'.at(-1)'o'


三、实战避坑:4 个高频雷区,新手必看

坑 1:方向搞反,用错 findfindLast

  • 想找第一个匹配 → 用 find()
  • 想找最后一个匹配 → 用 findLast()

坑 2:以为 toReversed() 会改原数组

永远不会修改原数组!如果确实需要修改,请用 reverse()

坑 3:忽略兼容性(但其实不用慌)

环境 支持情况
Chrome 92+
Firefox 90+
Safari 15.4+
Node.js 16+
Vue3 / React 18+
微信小程序(基础库 2.24.0+)

仅 IE 不支持,如需兼容,见下文方案。

坑 4:at() 越界返回 undefined,不报错

const arr = [1, 2];
console.log(arr.at(10));  // undefined
console.log(arr.at(-10)); // undefined
// 需自行判断是否有效

四、兼容性兜底方案(一行代码解决)

如果项目仍需支持旧环境(如 IE 或低版本 Node),只需:

【1. 安装 polyfill】

pnpm add core-js@3

【2. 入口文件引入】

// main.js 或 index.js
import 'core-js/stable';
// 自动补全 findLast、toReversed、at 等 API

效果:代码照常写新语法,打包后自动兼容!


五、谁在用这些新 API?

  • 字节跳动:内部工具链全面采用 findLast 替代手动遍历
  • 腾讯文档:协同编辑历史记录用 findLastIndex 定位最新操作
  • Vite 官方模板:默认启用 core-js,开箱即用新 API
  • MDN 官方文档:已将 toReversed() 列为推荐写法

结语:数组处理,本该如此优雅

这些新 API 不是“玩具特性”,而是对 JavaScript 数组操作范式的重大升级
当你能用 arr.findLast(x => x.valid) 代替 10 行 for 循环,你就知道——这波更新,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

紧急更新!JS数组又来新API!告别循环嵌套,一行代码直接起飞

做前端这么久,数组处理几乎占了日常逻辑的一半。

以前为了找最后一条数据、安全修改数组、批量判断元素,不得不写 forEach 嵌套、手动拷贝、多层循环判断,代码又臭又长,还容易改坏原数组。

现在 ES 新特性直接补齐短板,4 个超强数组新 API,Vue / React / 小程序 / Node.js 通用,不用装 Lodash,原生就能用,代码简洁度直接翻倍。


1. findLast / findLastIndex

从后往前找,再也不用 reverse + 拷贝

以前想拿数组最后一个满足条件的项,要这么写:

const list = [1, 3, 5, 7, 9]

// 老写法:又绕又容易错
const target = [...list].reverse().find(item => item > 4)
const index = list.length - 1 - [...list].reverse().findIndex(item => item > 4)

现在一行搞定:

// 从后往前找第一个满足条件的值
const target = list.findLast(item => item > 4) // 9

// 从后往前找索引
const index = list.findLastIndex(item => item > 4) // 4

真实业务场景 订单列表、消息列表、日志列表,永远只需要最后一条符合条件的数据,这个 API 直接封神。


2. toReversed / toSorted / toSpliced / with

immutable 安全操作,不污染原数组

以前用 reverse / sort / splice 都会直接改原数组,一不小心就翻车:

const arr = [3,1,2]
const newArr = arr.sort() // 原数组 arr 也被改了!

现在新 API 全部返回新数组,原数组纹丝不动:

const arr = [3,1,2]

// 反转(不改变原数组)
arr.toReversed()

// 排序(不改变原数组)
arr.toSorted()

// 切割删除(不改变原数组)
arr.toSpliced(1, 1)

// 替换指定下标的值(超级好用)
arr.with(1, 999) // 下标1替换成999

再也不用写 [...arr] 浅拷贝了,代码干净十倍。


3. Array.fromAsync

异步遍历神器,告别 Promise 地狱

处理异步数组时,以前你得这样:

const res = await Promise.all(ids.map(id => fetchItem(id)))

遇到需要流式、分批、异步生成的数组,forEach 根本顶不住。

现在直接:

const asyncIterable = createAsyncData() // 异步可迭代对象

// 直接转成数组,自带异步等待
const result = await Array.fromAsync(asyncIterable, item => {
  return item.data
})

Node.js 流、前端分页加载、异步列表处理,直接起飞。


4. group / groupToMap 数组分组

一行分组,告别手写循环

后端返回列表,前端要按类型/状态/时间分组,以前要写一堆:

const group = {}
list.forEach(item => {
  if (!group[item.type]) group[item.type] = []
  group[item.type].push(item)
})

现在:

const group = list.group(item => item.type)

返回结构直接就是:

{
  goods: [....],
  order: [....],
  coupon: [....]
}

想更严谨用 groupToMap,支持复杂 key:

const map = list.groupToMap(item => item.status)

5. 一些高频实用小语法

// 取最后一项(再也不用 arr[arr.length-1])
arr.at(-1)

// 判断是否所有项满足条件
arr.every(...)

// 判断是否至少一项满足
arr.some(...)

// 扁平化数组
arr.flat(Infinity)

避坑提醒(非常重要)

  • 这些新 API 不支持 IE
  • 小程序、现代浏览器、Node.js 18+ 基本都支持
  • 极低版本环境可以用 core-js 做兼容

总结

以前要写十几行的数组逻辑,现在一行就能搞定

  • 从后查找:findLast
  • 安全修改:toReversed / toSorted / toSpliced / with
  • 异步数组:Array.fromAsync
  • 数据分组:group / groupToMap

学会这一套,业务代码至少精简 50%,可读性、维护性直接拉满,面试说出来也是加分项。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

别再用显性水印!前端零宽隐形水印,实现内容溯源级版权保护,已封装复制即用

在前端版权、数据防泄露这种场景,我目前一般都是采用显性水印的方案。

包括目前企业微信这种级别的App也是在用显性水印。

image.png

但是显性水印的问题在于:要么遮挡页面内容、破坏视觉体验,要么极易被PS去除。

零宽字符水印凭借肉眼完全不可见、复制粘贴不丢失的核心优势,完全解决了显性水印的痛点。

实现原理

零宽字符属于Unicode标准内的特殊控制字符,这类字符无视觉渲染、不占用页面宽度、不影响文本排版

image.png

日常浏览、复制时完全无法察觉,但会被浏览器、编辑器、各类平台识别并保留。

  • U+200B(零宽空格):用于指代二进制 0

  • U+200C(零宽不连字):用于指代二进制 1

  • U+FEFF(零宽断行符):作为水印前缀标记,提升解析准确率,避免误识别

基于这种特性,加密就是将用户ID进行二进制转换,插入到文本中。

解密就是在文本中识别出零宽字符,再还原为普通字符。

完整代码

简单封装了一个面向对象封装的零宽水印工具类,复制进生产环境可直接使用,支持文本加密、水印解密两大核心功能。

/**
 * 零宽字符隐形水印工具类
 * 核心功能:文本水印加密、隐形水印解密
 * 适用场景:前端版权保护、内容溯源、防搬运追责
 */
class ZeroWidthWatermark {
  static #ZERO_CHAR = '\u200B';   // 零宽空格 = 二进制0
  static #ONE_CHAR = '\u200C';    // 零宽不连字 = 二进制1
  static #WATERMARK_PREFIX = '\uFEFF'; // 水印前缀校验符

  /**
   * 加密:给文本添加隐形水印
   * @param {string} text - 原始文本内容
   * @param {string} watermark - 水印信息(用户ID、溯源标识等)
   * @returns {string} 带隐形水印的文本
   */
  static encrypt(text, watermark) {
    if (!text || typeof text !== 'string' || !watermark) {
      throw new Error('加密失败:文本与水印内容不可为空');
    }

    try {
      const binaryWatermark = this.#textToBinary(watermark);
      const zeroWidthStr = this.#binaryToZeroWidth(binaryWatermark);
      const fullWatermark = this.#WATERMARK_PREFIX + zeroWidthStr;
      return text[0] + fullWatermark + text.slice(1);
    } catch (error) {
      console.error('零宽水印加密异常:', error);
      return text;
    }
  }

  /**
   * 解密:提取文本中的隐形水印
   * @param {string} encryptedText - 带水印的文本
   * @returns {string} 解析后的水印信息/状态提示
   */
  static decrypt(encryptedText) {
    if (!encryptedText || typeof encryptedText !== 'string') {
      return '无水印';
    }

    try {
      const zeroWidthChars = encryptedText.match(/[\u200B\u200C\uFEFF]/g) || [];
      if (zeroWidthChars.length === 0) return '无水印';

      const prefixIndex = zeroWidthChars.indexOf(this.#WATERMARK_PREFIX);
      if (prefixIndex === -1) return '无水印';
      const validChars = zeroWidthChars.slice(prefixIndex + 1);

      const binaryStr = this.#zeroWidthToBinary(validChars);
      if (!binaryStr || binaryStr.length % 8 !== 0) return '水印格式错误';

      return this.#binaryToText(binaryStr);
    } catch (error) {
      console.error('零宽水印解密异常:', error);
      return '解密失败';
    }
  }

  /** 文本转8位二进制字符串 */
  static #textToBinary(text) {
    return Array.from(text)
      .map(char => char.charCodeAt(0).toString(2).padStart(8, '0'))
      .join('');
  }

  /** 二进制转零宽字符 */
  static #binaryToZeroWidth(binary) {
    return binary.split('').map(bit => bit === '0' ? this.#ZERO_CHAR : this.#ONE_CHAR).join('');
  }

  /** 零宽字符转二进制 */
  static #zeroWidthToBinary(chars) {
    return chars.map(char => char === this.#ZERO_CHAR ? '0' : '1').join('');
  }

  /** 二进制转普通文本 */
  static #binaryToText(binary) {
    return Array.from({ length: binary.length / 8 }, (_, i) => {
      const byte = binary.slice(i * 8, (i + 1) * 8);
      return String.fromCharCode(parseInt(byte, 2));
    }).join('');
  }
}

使用示例

// 加密:添加隐形溯源水印
const originalText = "李剑一原创技术文章,禁止未经授权搬运转载";
const watermarkInfo = "userID:10086|publishTime:20260326|from:李剑一";
const watermarkedText = ZeroWidthWatermark.encrypt(originalText, watermarkInfo);

// 解密:提取溯源信息
const result = ZeroWidthWatermark.decrypt(watermarkedText);
console.log('解析出水印信息:', result);

总结

零宽水印能够无侵入式版权保护,而且在前端层面上实现还是比较简单的。

但是需要明确:零宽水印无法直接阻止爬虫爬取内容,因为爬虫会直接抓取页面文本,连带零宽字符一同获取。

如果需要防爬,可将零宽水印与字体加密、接口签名、行为验证、IP限流等方案结合使用,兼顾防护与溯源。

VTJ.PRO实践:接入TailwindCSS

VTJ.PRO没有内置TailwindCSS,可以通过增加依赖的方式支持 TailwindCSS。

增加 TailwindCSS 依赖

打开设计器的依赖管理面板,点击 + 添加 TailwindCSS 依赖。

填写以下信息:

  • 包名:tailwindcss
  • 版本:4.2.2
  • 导出名称:tailwindcss
  • 资源文件:https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4?.js

image.png

测试验证

在设计器页面管理面板,新建一个页面,打开AI助手,输入提示词:

在当前页面使用 tailwindcss 做一个演示 tailwindcss 用法的页面

image.png

页面生成效果

6a1391fa-b071-4307-b8a4-b21f27c5e4cc.png

经过验证,TailwindCSS 已经成功接入到VTJ.PRO

别再像个傻子一样天天手敲 Prompt 了!我用“拖拽连线”把 AI 驯服成了无情的 CRUD 机器(鳌虾 2.0 震撼发布)

前言

在 AI 代码生成工具层出不穷的今天,程序员面临着一个核心问题:如何更高效、更精准地让 AI 理解我们的需求? 传统的 AI 对话模式需要我们反复描述项目背景、手动关联各种文档和技能规范。这种模式不仅效率低下,还容易因为信息不完整导致 AI 产生“幻觉”,生成结果与预期相差甚远。

鳌虾(Aoxia-code) 正是为解决这些痛点而生。

在 1.0 版本中,鳌虾作为一个 npm 全局工具,首创了“可视化拖拽生成 AI 指令”的工作流,广受好评(详见1.0版本介绍)。 今天,我们正式推出 鳌虾 Aoxia-code 2.0!这一次,鳌虾完成了架构级的蜕变:从独立的 npm 工具,全面进化为沉浸式的 VS Code 插件,并重磅推出了基于流程图的复杂逻辑编排功能,让单页面真正串联成完整的业务流!


🌟 什么是鳌虾 2.0?

传统代码生成器通常依赖“硬编码模板(EJS/FreeMarker)”,一旦业务逻辑发生变化,模板就变得极难维护。 而 鳌虾 走的是另一条路: 元数据编排 + 技能规范约束 + AI 生成

在鳌虾 2.0 中,你不再需要手敲冗长的 Prompt。你只需在 VS Code 内部通过拖拽组件搭建页面骨架,再通过连线配置页面间的跳转逻辑。鳌虾会自动将这些视觉化的设计意图,结合项目中的技能规范文件,编译成一段高度结构化的 AI 指令。把这段指令发给 Trae/Cursor,完美契合你期望的源码瞬间生成!


🚀 鳌虾 2.0 核心演进与全景功能

一、 🔌 沉浸式 VS Code 插件体验与全局快捷键

鳌虾 2.0 告别了过去 npm install -g 启动本地服务的繁琐流程。 现在,你只需在 VS Code 插件市场搜索并安装 Aoxia-code Extension tool。按下 Ctrl+Alt+X (Mac 为 Cmd+Option+X)即可在编辑器内部瞬间唤出可视化工作台。它能原生读取你当前工作区的目录结构和规范文件,体验如丝般顺滑。

image.png

二、 📂 极其强大的物理/虚拟混合目录树

在前端开发中,对项目目录的把控至关重要。鳌虾 2.0 彻底重构了左侧栏目录树:

  • 挂载任意目录:在欢迎页或侧边栏,你可以通过一个精美的输入框直接指定想要读取的项目路径(如 src/views),留空则默认加载整个项目。
  • 深层智能读取与过滤:最高支持 8 级深度的文件树探测!同时内置了极其严格的黑名单规则(自动过滤 node_modules.git.husky 等无关产物),保证树形结构清爽无比,甚至能精确显示目录下的 .vue.ts 等业务代码文件。
  • 无感内联新建与 AI 翻译:想新建目录?点击加号,直接在树节点上内联输入(类似操作系统重命名)。你可以随意输入中文(如“测试业务”),鳌虾会自动在发给 AI 的指令中注入要求,让大模型在写代码时为你自动翻译成规范的英文(Kebab-case)文件夹名,丝滑无比!

三、 🕸️ 独创的全局与页面级流转拓扑图 (Flow Designer)

在 1.0 版本中,我们解决了“如何快速生成单个页面”的问题。但真实的业务往往是多页面联动的。 鳌虾 2.0 引入了基于流程图的可视化拓扑连线引擎,分为两个维度:

(这里非常适合放一张截图:FlowDesigner 的流程拓扑连线界面,展示模块之间或页面之间带有箭头和路由说明的连线)

  1. 系统级拓扑:宏观编排不同业务模块(Feature)之间的流转关系,系统架构一目了然。
  2. 页面级拓扑(模块内):双击进入模块后,你可以通过拖拽连线,配置列表页到详情页的交互逻辑。
    • 支持多种交互类型:路由跳转(Route)、弹窗打开(Dialog)、抽屉滑出(Drawer)。
    • 精准的 AI 翻译:这些连线数据最终会被鳌虾编译成明确的 AI 指令。例如连线后会生成:“当触发【查看详情】时,请使用【抽屉 (Drawer)】方式打开【详情页】”。AI 接收后,会自动为你写好 Drawer 组件引入和 v-model 状态控制逻辑!

image.png

image.png

四、 ⚡ 四大智能数据模型导入(告别手抄字段)

前端开发的第一步往往是看接口文档写字段。鳌虾内置了强大的解析引擎,支持四种零成本导入方式:

image.png

  • API 直连 (Apifox) :只需输入 Access Token 和项目 ID,鳌虾能直接拉取项目内的接口和数据模型,实现真正的无缝对接。
  • JSON 解析 :支持解析标准的 OpenAPI 3.0 JSON 格式。
  • SQL 智能解析 :直接把后端的 CREATE TABLE 语句贴进来!鳌虾会自动提取字段名、注释作为 Label,甚至能根据字段类型自动推导前端组件(比如 datetime 变日期选择器, varchar 变输入框,注释里带“状态”自动推导为下拉框)。
  • Java 实体类解析 :复制后端的 Entity/DTO 代码,鳌虾能识别 @ApiModelProperty 等注解,秒变前端结构字典。

五、 🎨 所见即所得的页面设计器 (Page Designer)

解析完字段后,进入可视化拼装环节:

image.png

image.png

  • 丰富的基础物料 :支持表单、表格、搜索栏、栅格布局(Grid-1, Grid-2)等多种组件。
  • 灵活的嵌套拖拽 :基于 vuedraggable 实现。你可以在 Tabs 标签页中嵌套布局容器,在布局容器中放置表格或表单,极其自由。
  • 多端响应式预览 :支持一键切换 PC 端和移动端视图,确保组件布局在不同设备下都合理。

六、 📋 智能读取与增强正则的技能体系 (Skill Config)

如何让 AI 生成的代码符合团队规范?

image.png

鳌虾 2.0 继承并强化了 1.0 的规范读取能力。它会自动扫描并读取当前 VS Code 工作区下的技能文件,优先级为: .trae/skills > .trae/rules > .cursor/rules > .windsurf/rules > .aocode/rules > docs/rules

  • 无遗漏的 <rules> 提取:只要在技能文件中标注了 <rules>...</rules>[CODE_RULES_START]...[CODE_RULES_END] 标签,鳌虾 2.0 会通过强大的正则匹配(支持多块扫描),完整提取并编号所有规则片段,精准输送给大模型。
  • 页面级技能分配:你可以为不同页面动态绑定没有任何默认干扰的规范。生成代码前,这些规范会被强制注入到最终的 Prompt 中,让 AI 始终在统一的框架下生成代码,从根本上减少“幻觉”。

📊 工具对比:鳌虾 vs 传统 AI 编程

对比维度 传统 AI 编程 鳌虾 AoCode 2.0
Prompt 输入 每次都要手敲完整描述 可视化配置,一键生成结构化指令
技能规范传递 手动复制粘贴或反复提及 自动读取并按需注入
多页面交互 AI 难以理解页面间的跳转与状态共享 流程图编排,清晰定义弹窗/抽屉/路由逻辑
信息完整性 容易遗漏关键约束条件 结构化输出,确保信息无遗漏
学习成本 需要学习复杂的 Prompt 编写技巧 零门槛,所见即所得

💡 鳌虾 2.0 的终极工作流体验

(这里可以作为总结,放一张最终生成 AI 指令后,代码在 VS Code 中自动写入过程的录屏动图)

  1. 唤出面板 :在 VS Code 中 Ctrl+Shift+P 执行 Aoxia: Open Aoxia
  2. 一键导模型 :贴入 SQL,生成带中文名称的数据字典。
  3. 拖拽组装 :左侧拖出栅格,右侧配好搜索栏和表格。
  4. 连线画逻辑 :切换到“流程拓扑”,把列表页连向新增页,设置交互为“抽屉”。
  5. 绑定技能 :为页面勾选 .trae/skills/table-spec.md 规范。
  6. 生成并发送 :点击生成指令,直接发送给右侧的 Trae/Cursor 对话框。
  7. 收工 :看着 AI 在你的编辑器里“唰唰唰”把包含弹窗、表格、表单且完全符合团队规范的完美代码写完。

🎉 立即体验

鳌虾 2.0 插件现已正式上架 VS Code 官方插件市场! 打开 VS Code 扩展面板,搜索 Aoxia-code Extension tool 即可安装体验!(开发者:Zhang-Lab

⚠️ 特别提示(针对 Trae 用户): 由于 Trae 默认使用的是其内部审核的插件市场,如果在 Trae 的插件面板中搜索不到 Aoxia-code Extension tool,直接用浏览器访问:marketplace.visualstudio.com/_apis/publi… 它会把.vsix文件下载下来。只需要打开trae,从VSIX安装。

image.png

我们不是要用低代码取代程序员,而是用**“可视化编排 + 流程定义 + AI”**把程序员从最无聊的增删改查和写 Prompt 的泥潭中解放出来,去思考更有价值的系统架构。

欢迎大家下载体验!如果有任何建议或发现了 Bug,欢迎在评论区留言交流!如果觉得好用,别忘了在插件市场留下一个五星好评哦~ ⭐️⭐️⭐️⭐️⭐️

windows下Vue3安装配置环境

一、Vue3基础环境配置

1、安装Node.js

打开nodejs官方网站nodejs.org/

2、验证安装

node -v
npm -v

3、安装Vue CLI

npm install -g @vue/cli

4、创建Vue项目

vue create my-vue-project

5、进入项目目录并启动

cd my-vue-project
npm run serve

6、构建项目(可选)

npm run build

二、Vue3配置Bootstrap框架

1、安装Bootstrap

npm install bootstrap@latest

2、在Vue组件中引入Bootstrap

import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle.min.js';

Vue3 的 v-model 双向绑定,90% 的人都用错了?(附 2026 最新避坑指南)

你的表单数据绑定了却不动?自定义组件 v-model 写了就是不生效?
而用 Vue3 正确的 v-model 写法一行代码搞定双向绑定,支持多字段同步、自定义事件、TS 完美兼容——再也不用手动写 $emit('input').sync 修饰符

如果你受够了:

  • 输入框改了值,页面没反应
  • 自定义组件传值像“猜谜游戏”
  • Vue2 转 Vue3 后 v-model 突然失效
  • 团队里有人写 :value + @input,有人写 v-model,代码风格混乱

那么,这篇 2026 年最新实操指南,就是为你写的——
不用翻文档,所有代码模板直接复制粘贴,今天就能写出零 bug 的双向绑定


一、先搞懂:Vue3 的 v-model,到底“新”在哪?

很多从 Vue2 过来的开发者,还在用老思维写 v-model,结果频频翻车。
Vue3 对 v-model 做了三大升级

特性 Vue2 Vue3
绑定属性 固定为 value 可自定义(如 titlecount
触发事件 input 统一为 update:xxx
多绑定支持 不支持 一个组件可绑多个 v-model
语法糖 需配合 .sync 原生支持,无需额外修饰符

一句话总结Vue3 的 v-model = 更灵活 + 更统一 + 更少代码


二、核心干货:v-model 3 大场景实战(附可运行模板)

场景1:基础表单绑定(覆盖 80% 日常开发)

适用于 <input><textarea><select>、复选框等。

【实操代码】(直接复制)

<template>
  <div class="form-demo">
    <!-- 文本输入 -->
    <input v-model="username" placeholder="账号" />
    
    <!-- 密码 -->
    <input v-model="password" type="password" placeholder="密码" />
    
    <!-- 多行文本 -->
    <textarea v-model="bio" placeholder="个人简介"></textarea>
    
    <!-- 复选框(布尔值) -->
    <label>
      <input type="checkbox" v-model="agree" />
      同意用户协议
    </label>

    <!-- 实时预览 -->
    <div class="preview">
      账号:{{ username }}<br/>
      密码:{{ password }}<br/>
      简介:{{ bio }}<br/>
      已同意:{{ agree ? '✅' : '❌' }}
    </div>
  </div>
</template>

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

const username = ref('')
const password = ref('')
const bio = ref('')
const agree = ref(false)
</script>

避坑提醒v-model自动忽略元素上的 valuechecked 属性,不要混用


场景2:自定义组件 v-model(组件通信必备)

让自定义组件像原生表单一样使用 v-model

1. 创建组件:MyInput.vue

<template>
  <div class="my-input">
    <span>自定义:</span>
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      placeholder="请输入..."
    />
  </div>
</template>

<script setup>
// 必须叫 modelValue!
const props = defineProps(['modelValue'])
// 必须 emit update:modelValue!
const emit = defineEmits(['update:modelValue'])
</script>

2. 父组件使用

<template>
  <MyInput v-model="customText" />
  <p>输入内容:{{ customText }}</p>
</template>

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

const customText = ref('')
</script>

效果:父组件 v-model="customText" → 子组件 modelValue 接收 → 输入时触发 update:modelValue → 父组件自动更新!


场景3:多 v-model 绑定(复杂表单神器)

一个组件同时绑定多个双向数据,比如姓名 + 年龄 + 邮箱。

父组件

<template>
  <UserForm 
    v-model:name="user.name"
    v-model:age="user.age"
    v-model:email="user.email"
  />
  <pre>{{ user }}</pre>
</template>

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

const user = reactive({
  name: '',
  age: 0,
  email: ''
})
</script>

子组件:UserForm.vue

<template>
  <div>
    <input v-model="nameProxy" placeholder="姓名" />
    <input v-model.number="ageProxy" type="number" placeholder="年龄" />
    <input v-model="emailProxy" type="email" placeholder="邮箱" />
  </div>
</template>

<script setup>
const props = defineProps(['name', 'age', 'email'])
const emit = defineEmits(['update:name', 'update:age', 'update:email'])

// 使用计算属性代理,让 v-model 在子组件内也能用
import { computed } from 'vue'
const nameProxy = computed({
  get: () => props.name,
  set: (val) => emit('update:name', val)
})
const ageProxy = computed({
  get: () => props.age,
  set: (val) => emit('update:age', val)
})
const emailProxy = computed({
  get: () => props.email,
  set: (val) => emit('update:email', val)
})
</script>

优势:父组件只需写 v-model:xxx,逻辑清晰,维护成本极低!


三、实战避坑:90% 的人都会踩的 3 个致命错误

坑1:绑定非响应式数据

// 错误 
let text = '' // 普通变量
// v-model="text" → 修改无效!

// 正确 
const text = ref('') // 响应式

坑2:自定义组件命名不规范

// 错误(Vue2 写法)
defineProps(['value'])
defineEmits(['input'])

// 正确(Vue3 标准)
defineProps(['modelValue'])
defineEmits(['update:modelValue'])

坑3:v-model:value 混用

<!-- 错误  -->
<input v-model="msg" :value="defaultValue" />

<!-- 正确  -->
<input v-model="msg" />
<!-- 或初始化时:const msg = ref(defaultValue) -->

四、进阶技巧:用 TS 让 v-model 更安全

// MyInput.vue (TypeScript 版)
<script setup lang="ts">
interface Props {
  modelValue: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
  (e: 'update:modelValue', value: string): void
}>()
</script>

类型检查 + 智能提示,杜绝拼写错误!


五、谁在用 Vue3 的 v-model?

  • 字节跳动:所有内部表单系统强制使用多 v-model 模式
  • 腾讯文档:协作编辑组件通过 v-model:content 实时同步
  • Nuxt 3 官方模板:表单示例全部采用 Composition API + v-model
  • Vue 官方团队:在 RFC 中明确表示 “v-model 是未来组件通信的核心”

结语:双向绑定,本该如此优雅

Vue3 的 v-model 不是“小改动”,而是对组件通信范式的重新定义
当你能用 v-model:titlev-model:count 一行搞定复杂交互,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Pinia 比 Vuex 好用 10 倍?Vue3 状态管理终于不折磨人了!(新手复制即用)

还在为 Vuex 的 statemutationactionmodule 四件套头疼?
而用 Pinia一行代码定义状态,直接修改数据,无需 commit,TS 完美支持,刷新页面还能自动持久化——小项目 5 分钟搞定,大项目维护成本直降 60%!

如果你受够了:

  • 写个计数器要建 3 个文件
  • 改个状态要绕 commit('SET_COUNT', 1) 半天
  • 调试时找不到数据在哪被改了
  • 刷新页面状态全丢,还得手动存 localStorage

那么,这篇手把手实操指南,就是为你写的——
不用看文档,所有代码模板直接复制粘贴,今天就能替换掉 Vuex


一、先说清:为什么 Pinia 是 Vue3 的“官方亲儿子”?

Vuex 是 Vue2 时代的产物,设计时没考虑 Composition API 和 TypeScript。
Pinia 由 Vue 核心团队打造,专为 Vue3 而生,直接解决 Vuex 所有痛点:

痛点 Vuex Pinia
配置复杂度 需创建 store/index.js + modules 一个文件就是一个仓库
修改状态 必须通过 mutation(commit 直接 this.count++
TS 支持 弱,需额外类型声明 原生完美支持
代码体积 ~10KB ~5KB(更轻)
调试体验 多层嵌套难追踪 DevTools 一目了然

大厂现状:字节、腾讯、阿里内部 Vue3 项目 100% 使用 Pinia,Vuex 已成历史。


二、核心干货:Pinia 3 步上手(附可运行模板)

第一步:安装(1 行命令)

# 推荐 pnpm
pnpm add pinia

# 或 npm / yarn
npm install pinia
yarn add pinia

第二步:全局注册(2 行代码)

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia' // ← 只需引入这个
import App from './App.vue'

const app = createApp(App)
app.use(createPinia()) // ← 注册
app.mount('#app')

避坑提醒不需要像 Vuex 那样写 new Store({}) 或分模块配置!


第三步:创建并使用仓库(核心!直接复制)

1. 创建仓库:src/store/counterStore.js

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // state:直接返回对象(响应式)
  state: () => ({
    count: 0,
    name: 'Pinia测试'
  }),

  // getters:计算属性(自动缓存)
  getters: {
    doubleCount: (state) => state.count * 2
  },

  // actions:同步/异步方法(直接修改 state!)
  actions: {
    increment() {
      this.count++ // 不用 commit!
    },
    async incrementAsync() {
      await new Promise(r => setTimeout(r, 1000))
      this.count++
    }
  }
})

2. 在组件中使用

<template>
  <div>
    <h3>{{ counterStore.name }}</h3>
    <p>当前:{{ counterStore.count }}</p>
    <p>2倍:{{ counterStore.doubleCount }}</p>
    <button @click="counterStore.increment">+1</button>
    <button @click="counterStore.incrementAsync">异步+1</button>
  </div>
</template>

<script setup>
// 引入 + 实例化(关键!)
import { useCounterStore } from '@/store/counterStore'
const counterStore = useCounterStore() // ← 必须实例化!
</script>

效果:状态、计算属性、方法全部自动暴露,无需 mapStatemapActions


三、实战避坑:90% 新手都会踩的 3 个致命错误

坑1:只引入不实例化,导致 undefined

// 错误
import { useCounterStore } from '@/store/counterStore'
console.log(useCounterStore.count) // 报错!

// 正确
const counterStore = useCounterStore()
console.log(counterStore.count) // 正常

坑2:在组件里直接改状态,破坏可维护性

// 不推荐(小型 demo 可以,项目别这么干)
counterStore.count = 999

// 推荐(统一走 actions,便于调试和复用)
counterStore.increment()

坑3:多个仓库用相同 ID,数据互相污染

// 错误
defineStore('user', { ... })
defineStore('user', { ... }) // ID 重复!

// 正确
defineStore('user', { ... })
defineStore('cart', { ... }) // ID 唯一

四、进阶技巧:一行代码实现状态持久化(刷新不丢)

默认 Pinia 状态刷新就没了?用官方插件 pinia-plugin-persistedstate,轻松搞定!

1. 安装插件

pnpm add pinia-plugin-persistedstate

2. 配置插件

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // ← 启用插件
app.use(pinia)
app.mount('#app')

3. 仓库开启持久化

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: { ... },
  persist: true // ← 就这一行!
})

效果count 自动存入 localStorage,刷新页面依然保留!


五、谁在用 Pinia?

  • 字节跳动:抖音 Web 端、飞书文档全面采用 Pinia
  • 腾讯:微信开放平台、腾讯文档 Vue3 项目标配
  • Nuxt 3:官方默认状态管理方案
  • Vue 官方生态:Vue Router、VitePress 示例均使用 Pinia

结语:状态管理,本该如此简单

Pinia 的价值,不只是“替代 Vuex”,而是让状态管理回归本质:直观、可维护、可扩展
当你不再为写 mutation 而烦恼,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

救命!Vue3 的 Composition API,居然能让我少写 80% 冗余代码?(新手也能直接抄)

你的 Vue 组件里是不是还在 datamethodscomputedwatch 之间来回跳转?
而用 Composition API一个 setup 函数搞定所有逻辑,代码量直降 80%,逻辑清晰到实习生都能看懂

如果你受够了:

  • Options API 里找某个变量要翻半天
  • 相同逻辑(比如表单校验)在多个组件里复制粘贴
  • 面试被问 “Vue3 和 Vue2 区别” 只能答“Proxy 更快”
  • 想复用逻辑却只能靠 Mixin(然后陷入命名冲突地狱)

那么,这篇手把手实操指南,就是为你写的——
不用死记硬背,所有代码模板直接复制粘贴,今天就能用上


一、先澄清一个误区:Composition API 不是“花里胡哨”,是真能救急

很多新手觉得:“Options API 能用,为啥换?”
但真相是:Options API 在复杂组件中,逻辑天然割裂

举个真实例子:写一个带防抖搜索 + 加载状态 + 错误提示的搜索框

  • Options API 写法

    • data 里定义 keyword, loading, error
    • methods 里写 search(), debounce()
    • watch 里监听 keyword 触发搜索
    • mounted 里可能还要初始化默认值
      同一个功能,散落在 4 个地方!
  • Composition API 写法

    const { keyword, loading, error, search } = useSearch()
    

一行代码,逻辑内聚,复用无痛

大厂现状:字节、腾讯、阿里内部 Vue3 项目 100% 强制使用 Composition API,面试必考。


二、核心干货:Composition API 3 个必学用法(附可运行模板)

1. script setup:所有逻辑的“入口”,一次搞定所有

这是 Vue3 官方推荐的写法,无需 return,自动暴露所有变量和方法

实操代码模板(直接复制到项目)

<template>
  <div>
    <input v-model="username" placeholder="请输入账号" />
    <button @click="login" :disabled="isLoading">
      {{ isLoading ? '登录中...' : '登录' }}
    </button>
  </div>
</template>

<script setup>
// 1. 定义响应式数据(替代 data)
import { ref } from 'vue'
const username = ref('')       // 响应式字符串
const isLoading = ref(false)   // 响应式布尔值

// 2. 定义方法(替代 methods)
const login = () => {
  isLoading.value = true
  // 模拟登录请求
  setTimeout(() => {
    console.log('登录成功,账号:', username.value)
    isLoading.value = false
  }, 1000)
}
// 3. 无需 return!<script setup> 自动暴露
</script>

避坑提醒:只有普通 setup() 函数才需要手动 return<script setup> 不用!


2. ref vs reactive:响应式数据的“两大神器”,别再用混了

记住口诀:**简单数据用 **ref复杂对象用 reactive

场景 推荐 API 修改方式 模板中使用
字符串、数字、布尔值 ref count.value = 1 {{ count }}
对象、数组 reactive user.name = 'Tom' {{ user.name }}

ref 实操示例】

import { ref } from 'vue'
const count = ref(0)

const increment = () => {
  count.value++ // 必须加 .value!
}

【reactive实操示例】

import { reactive } from 'vue'
const user = reactive({
  name: '',
  age: 0,
  hobbies: []
})

const updateUser = () => {
  user.name = 'Alice' // 直接修改,不加 .value
  user.hobbies.push('coding')
}

关键技巧:用 toRefs 解构 reactive 对象,保持响应式

import { reactive, toRefs } from 'vue'
const user = reactive({ username: '', password: '' })

// 解构后仍响应式
const { username, password } = toRefs(user)
username.value = 'test' // 有效!

3. 生命周期钩子:按需引入,不用写空方法

Vue3 生命周期需显式导入,更灵活,且避免无用代码。

【常用生命周期对照表】

Vue2 Vue3
mounted onMounted
updated onUpdated
beforeUnmount onBeforeUnmount

【实操示例:页面加载后请求数据】

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

const list = ref([])

onMounted(async () => {
  const res = await axios.get('/api/user/list')
  list.value = res.data
})
</script>

避坑提醒:生命周期钩子必须在 <script setup>setup() 内部调用,不能在外部!


三、实战避坑:90% 的人都会踩的 3 个致命错误

坑1:忘记给 ref.value,导致响应式失效

// 错误
const count = ref(0)
count = 1 // 页面不会更新!

// 正确
count.value = 1

坑2:用 reactive 创建简单数据

// 错误
const count = reactive(0) // reactive 只接受对象/数组
count = 1 // 响应式丢失!

// 正确
const count = ref(0)

坑3:为了“规范”强行封装,把简单逻辑搞复杂

正确姿势:只有跨组件复用的逻辑才封装成 Hook,否则直接写!


四、进阶技巧:用自定义 Hook 复用逻辑,效率拉满!

把重复代码(如表单校验、请求封装、本地存储)抽成 Hook,多个组件直接引入,少写 80% 代码

【实战示例:封装通用表单校验 Hook】

第一步:创建 hooks/useForm.js

// hooks/useForm.js
import { ref } from 'vue'

export const useForm = (rules) => {
  const form = ref({})
  const errors = ref({})

  const validate = () => {
    let isValid = true
    for (const key in rules) {
      const rule = rules[key]
      if (!form.value[key] && rule.required) {
        errors.value[key] = rule.message
        isValid = false
      } else {
        errors.value[key] = ''
      }
    }
    return isValid
  }

  return { form, errors, validate }
}

第二步:在组件中使用

<script setup>
import { useForm } from '@/hooks/useForm'

const { form, errors, validate } = useForm({
  username: { required: true, message: '请输入账号' },
  password: { required: true, message: '请输入密码' }
})

const login = () => {
  if (validate()) {
    console.log('提交数据:', form.value)
  }
}
</script>

<template>
  <div>
    <input v-model="form.username" />
    <span v-if="errors.username" class="error">{{ errors.username }}</span>
    
    <input type="password" v-model="form.password" />
    <span v-if="errors.password" class="error">{{ errors.password }}</span>
    
    <button @click="login">登录</button>
  </div>
</template>

效果:以后任何表单,只需 3 行代码引入,校验逻辑自动生效!


五、谁在用 Composition API?

  • 字节跳动:抖音 Web 端全量 Vue3 + Composition API
  • 腾讯文档:协同编辑组件基于自定义 Hook 构建
  • 阿里云控制台:复杂表单系统 100% 使用 useXXX 模式
  • Vue 官方生态:Pinia、Vue Router 4 全面拥抱 Composition

结语:少写代码,才是高级程序员的终极追求

Composition API 的价值,不只是“新语法”,而是用函数式思维组织逻辑,让代码可读、可测、可复用
当你不再为找变量翻遍整个文件,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

🚨 还在用 rem) 做大屏适配?用 vfit.js 一键搞定,告别改稿8版的噩梦!

 导读:欢迎来到《vfit.js 大屏适配指南》系列第 1 篇。在这个系列中,我们将带你彻底告别大屏适配的折磨,打通"痛点-解法-实战-优化-落地"的完整闭环。

上周,群里一个做政务大屏的兄弟心态崩了:"地图和图表用 rem 调了一整周,好不容易对齐了。结果去交付现场一看,客户的指挥中心用的是3840×2160 的超宽带鱼屏,整个页面全乱套了……"

底下瞬间刷出几十条"太真实了"。

做大屏开发的你,是不是也经常遭遇这些"社死时刻"?

  •  政务指挥中心:设计稿 1920×1080,现场屏幕长宽比极其奇葩,ECharts 图表直接挤成一团。
  • 工业监控大屏:用 rem 算来算去,DataV 的飞线动效死活偏了3 个像素。
  • 数据驾驶舱:老板在 iPad 竖屏打开大屏链接,质问你"为什么右边全白了?"

如果你中招了,请立刻停下手里写了一半的媒体查询!这篇指南,就是你的救命稻草!


🛑 为什么传统的适配方案,在大屏上必死无疑?

说到底,很多人没搞懂一个核心:可视化大屏,根本不是普通网页!

做普通后台管理系统,内容像"水流",用 flex、grid 响应式排版就行。
但做智慧城市、数字孪生这种大屏,页面是一幅"静态油画"。

📌 标题必须死死钉在正中间;
📌 3D 地图必须霸占绝对 C 位;
📌 两侧的数据面板哪怕字小点,也绝不能换行或错位。

所有元素的相对位置,必须焊死!

我们来看看你以前用的方案,为什么会翻车。

1️⃣ rem 方案:万恶的"单位转换地狱"

算比例、转 px,每次窗口一动就要重算。最要命的是,像 ECharts、高德地图、Three.js 这些第三方可视化库,底层全认 px!你用 rem,等于给自己挖了一个永远填不满的兼容坑。

2️⃣ vw/vh 方案:控制不住的"高度变形"

车联网驾驶舱时,宽度用 vw 撑满了,一旦屏幕比例从 16:9 变成 16:10,高度用 vh 就会拉伸,你的圆形仪表盘直接变成"椭圆",客户看着直摇头。


💡 终极解法:Scale 等比缩放,为什么你没早点用?

既然大屏是一幅画,那最完美的适配逻辑就是:把这幅画当成一个整体,等比例放大缩小!

就像在 PPT 里拖拽图片的对角线一样,不改变内部任何尺寸,只改变整体视野

  • • 设计稿是 px,你就写 px:零转换成本,所见即所得。
  • • 可视化库完美兼容:ECharts、DataV 闭眼用,再也不用担心偏移。
  • • 极致性能:利用 GPU 硬件加速,大屏不卡顿。

但手动写 Scale 有个巨坑:你要自己算比例、监听窗口、处理绝对定位失真……


🚀 登场:vfit.js,把你从加班中拯救出来

为了解决 Scale 方案的最后一公里痛点,vfit.js 诞生了。
这是一个专为 Vue 3 可视化大屏打造的轻量级适配神器。

不管你是做公安大屏、还是工厂看板,只需 3 行代码:

import { createApp } from 'vue'
import { createFitScale } from 'vfit'
import 'vfit/style.css'

const app = createApp(App)

// 告诉它设计稿尺寸,剩下交给 vfit
app.use(createFitScale({
  designWidth1920,   
  designHeight1080,  
  scaleMode'auto'    
}))

app.mount('#app')

就这么简单!代码一交,按时下班。


🎁 互动福利:大屏避坑资料包

你以前做大屏遇到过最奇葩的屏幕尺寸是多少?
👇 在评论区吐槽你的经历 👇

🔥 福利时间
关注公众号,后台回复【大屏模板】,即可免费领取:

1. vfit.js 开箱即用 Vue3 工程模板(带 ECharts 示例)

2. 大屏常见奇葩分辨率适配速查表

官方资源直达:


🔗 推荐阅读与下期预告

📚 推荐深度阅读

在开始源码探索前,强烈推荐先阅读这两份权威指南:


🔜 下期预告:别急着复制,先懂底层!

今天我们明确了:Scale 是大屏适配的唯一真理
但作为高级前端,只会调包可不行。万一在嵌套 iframe 的工业后台里失效了怎么办?

下一篇: 《02 - 5分钟看懂 vfit.js 大屏适配源码:政务/工业看板防变形黑科技,就这50行!》
我们将扒开 vfit 的底裤,带你搞懂 ResizeObserver 和 GPU 缩放的核心原理。我们下期见!

最新版vue3+TypeScript开发入门到实战教程之插槽slot详解

插槽概述

Slot,可翻译中文为插槽、空槽、钥匙槽。以下为官方定义Solt(插槽)是 Vue 提供的一种内容分发机制,允许父组件向子组件指定位置注入内容。简单理解为大门样式已经设计好,钥匙空槽预留,使用大门的人可以按装指纹锁、物理锁等锁。 Slot插槽分三种类型

  • 默认插槽
  • 具名插槽
  • 作用于插槽

默认插槽

概述

默认插槽是具名插槽的一个特例,实际类型应分成两类:

  • 具名插槽
  • 作用于插槽

默认插槽实例

  • 创建Fish,Fish组件提供标题、尾部,中间插槽内容由使用者提供
  • 创建App组件,引用Fish组件

App组件代码

<template>
  <div class="app">
    <Fish>
      <div>游泳的鲫鱼</div>
    </Fish>
     <Fish>
      <template>
        <div>会飞的鱼</div>
      </template>
    </Fish>
    <Fish>
      <template v-slot:default>
        <div>跃龙门的鲤鱼</div>
      </template>
    </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

Fish组件代码

<template>
  <div class="fish">
    <h2>头部</h2>
    <slot></slot>
    <h2>底部</h2>
  </div>

</template>

运行效果: 在这里插入图片描述 注意中间位置,会飞的鱼没有显示出来,默认插槽不需要使用template标签,若使用,必须给标签设置默认名称。

具名插槽

概述

在Fish组件中,可能会有很多个插槽, 如顶部、中部都可以设置一个插槽。使用名称来区分插槽:

  • 给slot设置名称
  • template标签设置slot名称

具名插槽实例

Fish组件代码

<template>
  <div class="fish">
    <h2>{{title}}</h2>
    <slot name="content">默认数据</slot>
    <slot name="footer">
      <h2>底部</h2>
    </slot>
  </div>
</template>
<script setup lang="ts">
defineProps(['title']);
</script>

App组件代码

<template>
  <div class="app">
     <Fish title="飞鱼">
      <template v-slot:content>
        <div>会飞的鱼</div>
      </template>
    </Fish>
      <Fish title="飞鱼">
      <template v-slot:footer>
        <h2>鲤鱼跃龙门</h2>
      </template>
      <template v-slot:content>
        <div>会飞的鱼</div>
      </template>
    </Fish>
     <Fish title="草鱼">
      <template #content>
        <div>吃草的鱼</div>
      </template>
      <template v-slot:footer>
        <h2>很爱水草</h2>
      </template>
      </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

运行效果 在这里插入图片描述

  • 引用第一个Fish组件时,当不使用名称为footer的插槽时,它显示默认值
  • 引用第二个组件说明,template显示位置取决于所选Slot
  • 引用第三个组件说明,v-slot:可用#缩写,如v-slot:content缩写成#content

作用域插槽

概述

插槽实际分两类,一是具名插槽,一是作用域插槽,两者区别:

  • 具名插槽数据与显示都在使用者
  • 作用域插槽的数据是在被引用的组件当中,使用者只负责显示数据
  • 通过slot标签可以将数据传递给template

作用域插槽实例

Fish组件代码

<template>
  <div class="fish">
    <h2>{{title}}</h2>
    <slot name="content" :data="fishs">默认数据</slot>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';

defineProps(['title']);
let fishs = reactive([
  { name: '鲫鱼', price: 10 },
  { name: '草鱼', price: 33 },
  { name: '娃娃鱼', price: 88 },
])
</script>

App组件代码

<template>
  <div class="app">
     <Fish title="飞鱼">
      <template v-slot:content="params">
        <ul>
          <li v-for="item in params.data">
            鱼:{{ item.name }}--价格:{{ item.price }}
          </li>
        </ul>
      </template>
    </Fish>
    <Fish title="草鱼">
      <template #content="{data}">
          <h4 v-for="item in data">
            鱼:{{ item.name }}--价格:{{ item.price }}
          </h4>
      </template>
    </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

运行代码查看效果 在这里插入图片描述 核心代码:

  • 传递数据:slot传递数据<slot name="content" :data="fishs">默认数据</slot>
  • 传递数据:template 接收数据<template v-slot:content="params">。 注意param结构赋值data

总结

类型语法特点使用场景
默认插槽<slot />一个组件只有一个主要内容区域
具名插槽<slot name="header" />

1.基于依赖追踪和触发的响应式系统的本质

前言

Vue1、Vue3、SolidJS、Mobx 它们的数据响应式基本原理都是一样的,具体区别只是设计理念和实现方式不一样。按以前读书时代考试做题一样,它们是同一类型的题目,都是基于依赖收集和触发的运行时的数据响应式,如果说你只会解答其中一道题,其他的题却不会解答,则说明你并没有真正彻底掌握这一类题。

为什么标题说“基于依赖追踪的响应式系统”,因为在前端响应式的框架有很多,但他们的实现原理却各有不同。 在前端一般谈到响应式框架,可能大家都会不约而同地联想到 Vue,除了 Vue 之外,也许还有人会想到 React 以及 Svelte、SolidJS。他们都有一个共同点,都是通过数据驱动视图。他们在实现方式上又互相有一些相似之处,其中 Vue 和 React 都采用了虚拟DOM技术,Vue 和 SolidJS 的数据响应式实现则都是采用了依赖追踪的方式,所以在数据响应式的实现方面 Vue 和 SolidJS 最相似,而 Svelte 的实现方式则跟 Vue 和 React 都不一样,Svelte 是基于编译响应式。当然 Vue 和 React 的具体实现技术也是不一样的,但它们在宏观层面则是一样,都是通过数据驱动视图,当数据发生变化时,视图会重新渲染,这种机制使得开发者只需要关注数据的变化,而不需要手动操作 DOM。Vue 通过数据劫持,使得操作数据需要额外的 API ,系统变能感知数据的变化,而 React 和 SolidJS 则需要手动调用 API 去触发数据变化。

手动操作 DOM 的上古时代

例如我们现在有一个这样的需求,有一个按钮 <button>0</button>,当我们点击按钮的时候,按钮中的文本就进行加 1。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-9" />
    <title>手动操作DOM</title>
  </head>
  <body>
    <button id="btn"></button>
    <script>
        let count = 0
        const btnEl = document.getElementById('btn')
        btnEl.textContent = count
        btnEl.addEventListener('click', function () {
            count++
            btnEl.textContent = count
        })
    </script>
  <body>
</html>

上述的 button 按钮是通过 HTML 进行渲染的,我们还可以通过 JavaScript API 进行创建。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-9" />
    <title>手动操作DOM</title>
  </head>
  <body>
-    <button id="btn"></button>
    <script>
        let count = 0
-        const btnEl = document.getElementById('button')
+        const btnEl = document.createElement('button')
+        const textNode = document.createTextNode(count)
+        btnEl.appendChild(textNode)
        btnEl.addEventListener('click', function () {
            count++
            btnEl.textContent = count
        })
+        document.body.appendChild(btnEl)
    </script>
  <body>
</html>

上述这种方式,在项目应用非常庞大的时候,开发效率是非常低下的,同时维护成本却又非常高的,所以就出现了像 React、Vue 这种通过数据进行驱动视图的前端框架。

通过数据驱动视图

虽然 Vue 和 React 在具体的实现技术方案上差异是非常大的,但在宏观层面它们则是一样的,都是通过操作数据来驱动视图,开发者只需要关注数据的变化,而不需要手动操作 DOM,同时它们都是通过虚拟DOM 和 Diff 算法进行实现响应式的,当组件的状态发生改变时,Vue 和 React 都会重新生成新的虚拟 DOM,并通过 Diff 算法比较新老虚拟 DOM 的差异,然后只更新需要更新的部分到真实 DOM 上,从而提高性能。

不管是 Vue 还是 React 的虚拟DOM 本质上都是一个对象,上面记录着一些真实 DOM 的信息,比如 type、props、children,我们这里简单模拟一下,并通过虚拟DOM 和 Diff 算法来改写上面的例子。

首先我们通过一个 createElement 的函数来创建一个节点的虚拟DOM对象,这里跟 React 的对齐,children 节点在 props 中,Vue 的 children 是跟 props 同级的,这些差别对我们进行宏观研究不重要。

function createElement(type, props) {
    return {
        type,
        props
    }
}

接着我们创建一个函数组件 App。

count = 0
function App (){
    return createElement('button', {
        onClick: () => {
            count ++
            setCount(count)
        },
        children: count
    })
}

接着我们通过以下方式把创建的虚拟DOM 挂载到根节点上。

render(App(), document.getElementById('app'))

接着我们实现 render 方法

let oldVnode
function render(vNode, container) {
    if (!oldVnode) {
        oldVnode = vNode
        const el = document.createElement(vNode.type)
        // 保存真实DOM 到虚拟DOM 的 el 属性上,将来更新的时候,不用重新创建,从而达到提高性能效果
        oldVnode.el = el
        const textNode =  document.createTextNode(vNode.props.children)
        el.appendChild(textNode)
        // 绑定虚拟DOM 上的事件
        el.addEventListener('click', vNode.props.onClick)
        container.appendChild(el)
    } else if(oldVnode.props.children !== vNode.props.children) {
        oldVnode.el.textContent = vNode.props.children
    }
}

我们在这里非常简单且宏观的实现了新老虚拟DOM 的对比,当不存在旧虚拟DOM 则是挂载阶段,创建真实DOM,并保存到虚拟DOM 的 el 属性上,将来更新的时候,不用重新创建,从而达到提高性能效果。在进行新老虚拟DOM 的时候,我们这里只比较 children 一个属性,如果新老 children 不一样就把新的虚拟DOM 上的 children 的值更新到对应的真实DOM 上。

我们在上面的 App 函数中的 props 中的点击事件函数中有一个 setCount 的方法还没实现,它的实现可以抽象成如下:

function setCount(val) {
    count = val
    render(App(), document.getElementById('app'))
}

从上述代码可以看到 setCount 的实现很简单,这个方法就是在点击之后进行更新数据 count 的,并且在更新数据 count 的同时重新渲染视图,把新的 count 值显示到页面上。

以下是测试效果:

00.gif

我们在上述例子中通过极简的代码,从宏观层面阐明了 React 的响应式原理,通过操作数据来驱动视图,开发者只需要关注数据的变化,而不需要手动操作 DOM,同时它们都是通过虚拟DOM 和 Diff 算法进行实现响应式的,当组件的状态发生改变时,Vue 和 React 都会重新生成新的虚拟 DOM,并通过 Diff 算法比较新老虚拟 DOM 的差异,然后只更新需要更新的部分到真实 DOM 上。

其实上述这段 React 的响应式原理放在 Vue 的响应式原理也是成立的,最大的不同则是 React 更新数据需要通过 setCount 函数,也就是所谓手动触发,而 Vue 则是自动触发的。那么下面我们来看看 Vue 的响应式是怎么实现的。

基于依赖追踪的响应式

我们知道 Vue1 的响应式数据是通过 Object.defineProperty 来实现的。基于 Object.defineProperty 来实现需要初始化对对象的每一个熟悉进行劫持监听。

例如我们有这么这个对象 const data = { count:0 },那么我们需要进行以下操作:

const data = { count: 0 }
Object.keys(data).forEach(key => {
   Object.defineProperty(data, key, {
    get() {
        return data[key]
    },
    set(val) {
        data[key] = val
    }
   }) 
})

上述这个写法会造成内存栈溢出,主要是因为在 Object.defineProperty 的 getter 中读取 data[key] 会触发 getter 循环读取,从而造成死循环。

00.png

我们可以把 getter 中 data[key] 的取值放在进行 Object.defineProperty 监听之前。

const data = { count: 0 }
Object.keys(data).forEach(key => {
+   const val = data[key]
   Object.defineProperty(data, key, {
    get() {
+        return val
    },
    set(val) {
        data[key] = val
    }
   }) 
})

但这样 val 会被循环取值进行了覆盖,没办法正确读取每个 key 的值,为了可以读取每个 key 的值,我们可以通过闭包的形式把每个 key 的值缓存下来。

const data = { count: 0 }
Object.keys(data).forEach(key => {
   defineReactive(data, key, data[key]) 
})

function defineReactive(data, key, val) {
    Object.defineProperty(data, key, {
        get() {
            return val
        },
        set(newVal) {
            val = newVal
        }
    })
}

接下来我们要做的就是在 getter 中进行依赖收集,然后在 setter 中进行依赖触发,这本质上就是一个订阅发布模式。

const data = { count: 0 }
+ // 声明一个依赖存储中心
+ const subscribers = new Set()
+ // 需要收集的依赖,在 Vue1 叫 wachter,Vue3 中叫 effect,本质上就是一个订阅者,关于发布订阅模式,我们后续再详细介绍
+ let activeEffect
Object.keys(data).forEach(key => {
   defineReactive(data, key, data[key]) 
})

function defineReactive(data, key, val) {
    Object.defineProperty(data, key, {
        get() {
+            // 存在依赖就把依赖收集到依赖存储中心
+            if(activeEffect) subscribers.add(activeEffect)
            return val
        },
        set(newVal) {
            val = newVal
+            // 值更新了,就需要去把依赖存储中心中的订阅者全部重新执行一遍
+            subscribers.forEach(sub => sub())
        }
    })
}

我们在上述代码中通过一个全局变量 subscribers 在响应式数据的 getter 把依赖收集到 subscribers 中, 在 setter 中则把 subscribers 中收集到的依赖进行循环遍历重新执行一遍,从而实现了依赖追踪和触发。

那么现在我们有了响应式数据 data 之后,我们就可以对我们前面的例子中的 App 函数中的 count 数据进行更改了,我们之前实现的是 React 的方式,我们现在要把它改成 Vue 的方式。

- count = 0
function App (){
    return createElement('button', {
        onClick: () => {
-            count ++
-            setCount(count)
+            data.count ++
        },
-        children: count
+        children: data.count
    })
}

此外渲染函数的执行方式也需要改成一个副作用函数,通过副作用函数进行调用执行。

    activeEffect = () => {
        render(App(), document.getElementById('app'))
    }

    activeEffect()

    activeEffect = null

我们把一个副作用函数赋值给了变量 activeEffect,然后再执行 activeEffect,那么在执行 activeEffect 函数的时候就会去执行 rander 函数,并通过 App 函数生成虚拟DOM,在 App 函数中对虚拟DOM 的 children 属性赋值的时候是通过读取响应式数据 data 中的 count 值,那么这时就会触发 count 属性的 getter,然后就会在 getter 中进行依赖收集,在 getter 中很明显这个时候 activeEffect 是有值的,所以会进行依赖收集。当点击的时候,就会触发 data.count ++ 的执行,这时就会触发 count 属性 setter,然后就会在 setter 中进行依赖触发。

以下是测试效果:

00.gif

至此我们把 Vue 的数据响应式也通过最少的代码量阐明了,以上的 Vue 的响应式原理估计很多同学都非常清楚,因为这是面试被问几率非常高的题目。我在这里重复讲解,是为了对比 React 和 Vue 响应式原理的差别,总的来说,React 和 Vue 的响应式原理在宏观层面是有非常大的相同之处的,都是通过操作数据来驱动视图,开发者只需要关注数据的变化,而不需要手动操作 DOM,同时它们都是通过虚拟DOM 和 Diff 算法进行实现响应式的,当组件的状态发生改变时,Vue 和 React 都会重新生成新的虚拟 DOM,并通过 Diff 算法比较新老虚拟 DOM 的差异,然后只更新需要更新的部分到真实 DOM 上。在宏观层面 React 和 Vue 响应式原理最大的不同则是数据触发方式的不同,React 是数据变更后需要开发者通过手动调用 React 提供的 API 进行触发视图的更新,而 Vue 则是自动触发的,因为 Vue 的状态数据是响应式的,而 React 的状态数据不是响应式的。

我们一般在很多的文章中都只讲了 Vue1 的数据响应式原理是通过 Object.defineProperty 来实现的,那么是否只能通过 Object.defineProperty 来实现呢?很明显不是,我们上述例子中的 data,我们现在如果想给它新增属性 data.name,那么 Object.defineProperty 是无法进行监听追踪的,所以我们通过一个工具来对 data 也进行监听。

function observe (data) {
    // 给对象 data 添加一个属于 data 对象的依赖存储中心
    data.__ob__ = new Set()
    Object.keys(data).forEach(key => {
        const value = data[key]
        defineReactive(data, key, value) 
    })
}

那么在 getter 中要对对象的依赖也进行收集

function defineReactive(data, key, val) {
    Object.defineProperty(data, key, {
        get() {
            // 存在依赖就把依赖收集到依赖存储中心
            if(activeEffect) {
                subscribers.add(activeEffect)
                // 如果读取的值是对象,那么还要给这个对象进行依赖收集,并且新的对象也要通过 observe 进行监听
                if(Object.prototype.toString.call(val) === '[object Object]') {
                    observe(val)
                    val.__ob__.add(activeEffect)
                }
            } 
            return val
        },
        set(newVal) {
            val = newVal
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
            subscribers.forEach(sub => sub())
        }
    })
}

然后我们要专门通过一个单独的 API 来给响应式对象添加属性,并且在添加属性之后进行依赖触发。

function set(target, key, val) {
    target[key] = val
    // 新添加的属性也需要通过 Object.defineProperty 进行监听
    defineReactive(target, key, val)
    const ob = target.__ob__
    // 进行对象的依赖触发
    ob.forEach(sub => sub())
}

接着我们把 App 函数进行以下修改

// 修改数据结构
const data = { data: { count: 0 } }
// 通过 observe 处理
observe(data)
function App (){
    return createElement('button', {
        onClick: () => {
            set(data.data, 'count1', 2)
        },
        children: JSON.stringify(data.data)
    })
}

然后我们重新运行代码结果如下:

01.gif

我们看到可以正常运行,但对象中的私有属性 __ob__ 也显示出来了,我们希望它不要被枚举出来,我们可以通过 Object.defineProperty 对它进行以下设置。

function observe (data) {
    // 给对象 data 添加一个属于 data 对象的订阅者中心
-    data.__ob__ = new Set()
+    Object.defineProperty(data, '__ob__', {
+        value: new Set(), // 属性的值,默认为 undefined
+        enumerable: false, // 属性是否可枚举,默认为 false
+        writable: true, // 值是否可写,默认为 false
+        configurable: true // 属性是否可配置,默认为 false
+    })
    Object.keys(data).forEach(key => {
        const value = data[key]
        defineReactive(data, key, value) 
    })
}

修改后再进行测试,我们可以看到 __ob__ 属性不再出现了。

02.gif

可以通过 Object.defineProperty 对数组进行监听,但监听不了 push、pop、shift 等对数组进行操作的方法,所以我们需要对数组的操作方法进行重写,重写的方法就是覆盖数组数据上的原型对象 __proto__

function observe (data) {
// 省略 ...
+   if (Array.isArray(value)) {
+      // 如果是数组则重新数组上的原型
+      value.__proto__ = {
+          join(val) {
+             // 通过原生数组上方法进行调用
+             return Array.prototype.join.call(value, val)
+          },
+          push(val) {
+             // 通过原生数组上的方法进行调用
+             Array.prototype.push.call(value, val)
+             subscribers.forEach(sub => sub())
+          }
+      }
+   } else {
        Object.keys(data).forEach(key => {
            const value = data[key]
            defineReactive(data, key, value) 
        })
+    }
}

我们这里只测试 join 和 push 方法,而 join 方法没有更改到数据,所以是不用进行依赖触发的。

然后我们对 App 应用也进行修改一下,以便测试数组响应式数据

// 数据
const data = ['cobyte']
// 通过 bserver 处理
observe(data)
function App (){
    return createElement('button', {
        onclick: () => {
            data.push('=')
        },
        children: data.join('-')
    })
}

测试结果如下:

03.gif

小结

至此,我们通过手写已经基本实现了 Vue1 的数据响应式原理,我们可以通过对 Vue2 数据响应式原理的分析进行一个宏观总结。我们需要在实践中总结规律,然后又通过规律更好地指导实践

首先我们都知道 Vue1 的数据响应式原理是通过 Object.defineProperty 实现的,通过 Object.defineProperty 可以监听一个对象的属性的读取(getter)和修改(setter),这样就可以在 getter 的时候进行依赖收集,在 setter 的时候进行依赖触发。但 Vue2 不单单只是通过 Object.defineProperty 实现数据响应式的,因为只有被 Object.defineProperty 初始化了的属性才可以进行监听,而当一个对象新增一个属性时,则监听不了。这时我们需要通过额外的手段来实现对象新增属性时的监听,具体方案就是通过给对象新增一个私有的属性 __ob__,去记录属于该对象的依赖,当该对象新增属性时则触发该对象的依赖重新执行。同时 Object.defineProperty 也监听不了数组的原生方法,例如:push、pop、shift、unshift、splice、sort、reverse,我们观察一下这些数组方法发现都有一个共同特点,就是他们都会修改数组,使数组数据发生变化,那么根据数据响应式的原理,数据发生了改变就需要进行依赖触发,那么我们需要对响应式数据类型为数组的数据进行重写它们的原型,这样我们就可以在响应式数组通过 push、pop、shift、unshift、splice、sort、reverse 方法修改数组的时候进行依赖触发了。

我们可以总结出,不管是通过 Object.defineProperty 进行监听对象属性还是通过给对象添加私有属性 __ob__,去记录该对象的依赖,还是重写数组的原型方法,目的都只有一个:进行数据的依赖追踪和触发。

我们还可以进一步进行总结规律:这种基于依赖追踪的响应式系统,并不是某一种技术,而是一种模式。核心只有一个,就是在数据读取的时候进行依赖收集,在数据更改的时候进行依赖触发。

基于这种指导思想,我们就可以很好去实践 Vue2 的数据响应式原理了。

Vue3 只是通过 Proxy 实现数据响应式吗

Vue3 是通过 Proxy 对数据实现 getter/setter 代理,从而实现响应式数据,然后在副作用函数中读取响应式数据的时候,就会触发 Proxy 的 getter,在 getter 里面把对当前的副作用函数保存起来,将来对应响应式数据发生更改的话,则把之前保存起来的副作用函数取出来执行。这个过程跟 Vue2 是一样的,只是实现细节不一样。

实现起来也非常简单:

function reactive(data) {
    return new Proxy(data, {
        get(target, key) {
            // 存在依赖就把依赖收集到依赖存储中心
            activeEffect && subscribers.add(activeEffect)
            return Reflect.get(target, key) 
        },
        set(target, key, val) {
            const result = Reflect.set(target, key, val)
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
            subscribers.forEach(sub => sub())
            return result
        }
    })
}

我们再把 App 函数进行修改:

const data = reactive({count: 0})
function App (){
    return createElement('button', {
        onClick: () => {
            data.count ++
        },
        children: data.count
    })
}

测试结果如下:

00.gif

我们这里不是为了深入探讨 Vue2 的数据响应式原理的,而是为了验证上面实现 Vue2 的数据响应式原理总结的规律。也就是:这种基于依赖追踪的响应式系统,并不是某一种技术,而是一种模式。核心只有一个,就是在数据读取的时候进行依赖收集,在数据更改的时候进行依赖触发。后续我们基于数据响应式原理的规律便可以很好去理解其他数据响应式系统了,例如 React 的状态管理库——Mobx、SolidJS,我们在后续也将探讨这些库的数据响应式原理的实现。

Vue1 是通过 Object.defineProperty 实现对数据的读写监听,但由于 Object.defineProperty 的局限性,Vue2 并不只是通过 Object.defineProperty 实现数据响应式的,但都为了实现在数据读取时进行依赖收集,在数据更改时进行依赖触发。Vue3 则通过新的 API:Proxy 可以实现对数据的读写监听,但核心也是为了实现在数据读取时进行依赖收集,在数据更改时进行依赖触发。

那么问题来了,Vue1 并不只是通过 Object.defineProperty 实现数据响应式的,那么 Vue3 只是通过 Proxy 实现了数据响应式吗?

其实这个问题可以转化得更具体一些,Vue2 的 reactive 和 ref 的底层实现原理是一样的吗?有人认为 ref 和 reactive 的底层实现原理都是一样的,也就是 ref 也是通过 reactive 实现的,也就是 ref 也是通过 Proxy 实现的。如果说 ref 和 reactive 的底层实现原理不一样的话,也就是说 Vue3 可以不通过 Proxy 实现数据的响应式。

很明显 Vue3 可以不通过 Proxy 实现数据的响应式的,也就是 ref 和 reactive 的底层实现原理是不一样的。那么根据我们上面总结的实践规律,我们只需要可以实现在数据读取的时候进行依赖收集,然后在数据更改的时候进行依赖触发就可以了。那么明显可以使用 Vue2 中的 Object.defineProperty 中的 getter/setter,这种方式也叫属性访问器。根据上面 Vue2 的数据响应式原理我们可以知道如果通过 Object.defineProperty 实现对数据的监听,还要通过闭包的方式,就显得不够简洁。那么属性访问器除了使用 Object.defineProperty 进行显式声明之外,还可以通过字面量的方式,本质还是属性访问器

例如:

function ref(value) {
    return {
        _value: value,
        get value() {
            // 存在依赖就把依赖收集到依赖存储中心
            activeEffect && subscribers.add(activeEffect)
            return this._value
        },
        set value(val) {
            this._value = val
            // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
            subscribers.forEach(sub => sub())
        }
    }
}

然后我们通过 ref 函数来创建一个响应式数据,再修改 App 函数。

const count = ref(0)

function App (){
    return createElement('button', {
        onClick: () => {
            count.value ++
        },
        children: count.value
    })
}

测试运行结果:

00.gif

这也就是 Vue2 的 ref API 的实现原理,当然在 Vue3 源码中如果 ref 传进来的值是一个引用对象的话,还是通过 reactive 进行实现。此外在 Vue3 的源码中 ref API 是通过一个 class 类来实现的,但原理是一样的。

我们下面也可以简单实现一下:

class RefImpl {
    _value
    constructor(value) {
        this._value = value
    }
    get value() {
       // 存在依赖就把依赖收集到依赖存储中心
       activeEffect && subscribers.add(activeEffect)
       return this._value 
    }
    set value(val) {
        this._value = val
        // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
        subscribers.forEach(sub => sub())
    }
}

function ref(value) {
    return new RefImpl(value)
}

修改好的测试结果还是一样的。

00.gif

通过沙箱模式实现依赖追踪的数据响应式

通过上面对 Vue1 和 Vue3 的数据响应式原理的实现与分析,我们知道都借助了 JavaScript 的原生 API(Object.defineProperty 和 Proxy) 来实现依赖追踪的响应式系统,那么不借助 JavaScript 原生 API 还可以实现依赖追踪的响应式系统吗? 我们上面总结出的结论是,基于依赖追踪的响应式系统的本质是在读取数据的时候收集依赖,在更新数据的时候触发依赖。那么基于此原理,我们只需要把读写进行分离那么可以实现了。

我们把上面第一版 ref 的实现通过闭包的形式改造一下:

function ref(value) {
    const s = {
        value
    }

    function getState() {
        return s.value
    }

    function setState(val) {
        s.value = val
    }
    return [getState, setState]
}

const [getState, setState] = ref(0)

console.log('初始值:', getState())
// 修改
setState(1)
console.log('修改后:', getState())

我们可以看到通过闭包的我们实现了读写分离,这种模式有一个专业的术语叫:沙箱模式,这样我们就可以在读取数据的时候收集依赖,在修改数据的时候触发依赖了。

function ref(value) {
    const s = {
        value
    }

    function getState() {
        // 存在依赖就把依赖收集到依赖存储中心
        activeEffect && subscribers.add(activeEffect)
        return s.value
    }

    function setState(val) {
        s.value = val
        // 值更新了,就需要去把依赖存储中心中的依赖全部重新执行一遍
        subscribers.forEach(sub => sub())
    }
    return [getState, setState]
}

接着我们把 App 函数也进行修改一下:

const [count, setCount] = ref(0)

function App (){
    return createElement('button', {
        onClick: () => {
            setCount(count() + 0)
        },
        children: count()
    })
}

其实上述这种实现依赖追踪的响应式系统的方式就是 SolidJS 的响应式原理,长得像 React,实际上是 Vue。所以我们只要把核心原理搞清楚,就可以举一反三了,像读书时候一样,以后同类型的题目,你都回作答了。当然 SolidJS 的响应式原理远不止这些,我们将在后续章节继续进行深入探讨,搞明白了 SolidJS, Vue Vapor 的原理也非常容易理解了。

总结

上述所有例子中的依赖收集和触发的过程,本质就是一个发布订阅模式,而关于发布订阅模式,我们将在下一篇文章中进行详细介绍。当我们掌握了发布订阅模式后,我们再去理解这些通过依赖收集和触发实现的数据响应式系统,就会如鱼得水。

上述文章写于:2023 年,由于个人原因今年 2026 年发布。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

❌