阅读视图

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

[前端]单文件上传组件

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

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

组件源码

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

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

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

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

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

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

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

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

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

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

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

  return true;
}

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

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

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

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

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

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

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

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

<style scoped></style>

使用示例

示例代码:

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

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

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

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

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

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

代码解释:

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

显示效果:

image.png

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

image.png

选择文件后的效果:

image.png

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

image.png

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

image.png

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

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

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

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

有四个层次的意义

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

第一环节相对简单

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

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

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

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

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

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

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

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

后台图书管理系统

正式开工:构建项目雏形

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

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

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

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

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

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

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

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

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

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

新建逻辑&业务对应

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

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

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

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

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

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

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

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

新建逻辑&业务对应

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

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

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

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

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

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

新建逻辑&业务对应

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

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

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

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

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

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

新建逻辑&业务对应

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

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

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

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

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

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

新建逻辑&业务对应

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

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

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

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

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

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

新建逻辑&业务对应

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

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

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

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

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

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

逐模块建目录的核心意义

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

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

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

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

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

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

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

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

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

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

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

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

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

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite' 
import {ElementPlusResolver} from 'unplugin-vue-components/resolvers'
import { fileURLToPath,URL } from 'node:url'

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

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

2. 插件功能配置

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

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

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

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

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

3. 打包路径配置

base:'./'

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

4. 路径别名配置

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

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

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

整合配置代码

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

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

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

项目底层基础架构三件套

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

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

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

1. 路由配置

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

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

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

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

export default router

关键词解释

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

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

2. Axios 请求封装

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

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

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

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

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

export default service

关键词解释

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

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

3. Pinia 全局状态管理

文件目录结构

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

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

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

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

export default pinia
② 用户状态模块

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

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

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

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

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

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

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

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

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

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

main.js全局挂载配置

文件路径: src/main.js

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

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

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

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

代码说明

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

一、现阶段编写

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

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

1. 页面整体架构搭建

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

2. 页面样式完善

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

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

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

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

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

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

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

四、本阶段编写代码

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

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

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

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

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

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

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

.menu {
  border-right: none;
}

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

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

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

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

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

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

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

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

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

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

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

一、现阶段编写

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

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

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

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

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

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

1. 表单双向数据绑定

2. 表单校验规则

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

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

5. 登录完成页面跳转

四、本阶段代码

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

<script setup>
</script>

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

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

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

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

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

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

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

本阶段可完整实现的内容

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

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

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

后续补充的功能逻辑

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

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

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

4. 路由重定向细节优化

本阶段代码

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

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

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

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

export default router

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

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

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

本阶段可完整实现的内容

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

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

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

后续补充的功能逻辑

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

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

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

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

本阶段代码

import { defineStore } from 'pinia'

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

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

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

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

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

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

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

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

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

.stat-card {
  padding: 18px;
}

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

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

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

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

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

.welcome p {
  margin: 0;
}

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

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

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

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

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

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

本阶段可完整实现的内容

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

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

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

后续补充的功能逻辑

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

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

3. 搜索筛选功能

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

本阶段代码

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

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

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

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

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

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

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

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

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

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

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

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

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

本阶段实现的内容

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

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

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

后续补充的功能逻辑

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

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

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

本阶段代码

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

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

    <el-divider />

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

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

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

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

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

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

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

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

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

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

Layout布局页面 业务逻辑回填

一、template 模板部分改动

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

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

原有空标签  

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

修改回填后

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

二,script 脚本逻辑

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

引入退出图标

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

.menu {
  border-right: none;
}

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

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

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

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

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

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

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

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

登录页面业务逻辑回填

一、template 模板部分改动

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

1. el-form 表单标签

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

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

新增数据双向绑定

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

新增数据双向绑定

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

新增点击登录触发事件

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

二、script 脚本逻辑

模块1:引入项目依赖

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

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

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

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

作用:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

一、template 模板改动说明

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

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

回填新增内容

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

 

二、script 脚本新增

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  dialogVisible.value = false
  pagination.page = 1
}

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

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

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

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

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

一、template 模板改动说明

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

1. 头像标签改动

回填后

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

新增双向数据绑定

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

二、script 脚本新增逻辑

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

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

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

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

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

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

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

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

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

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

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

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

    <el-divider />

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

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

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

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

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

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

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

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

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

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

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

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

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

Pinia User.js业务逻辑补全

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

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

新增:

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

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

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

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

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

新增&改动说明:

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

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

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

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

第3步:新增 getters 计算属性

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

作用:

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

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

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

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

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

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

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

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

完整 Pinia 最终代码

import { defineStore } from 'pinia'

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

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

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

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

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

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

router路由配置业务逻辑补全

分步增补改造

步骤1:引入 Pinia 用户仓库

在顶部新增导入:

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

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

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

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

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

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

改造后单个路由示例:

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

步骤3:新增 404 兜底路由

加到 routes 最后一项:

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

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

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

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

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

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

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

  return true
})

三大核心功能:

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

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

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

完整路由代码

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

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

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

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

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

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

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

  return true
})

export default router

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

调试部分

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

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

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

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

项目现存不足 & 可优化点

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


采集模块示例

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

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

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

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

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

let installed = false;

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

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

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

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

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

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

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

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

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

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


main.js

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

initClientMonitoring();

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

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


Vue 插件封装(可选)

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

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

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

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


router.afterEach 是否必要

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

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


Vue.config.errorHandler

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

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

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


上报接入

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Vue2:在Console输入vm = document.querySelector('vue-app').__vue__,获取根实例,可查看vm.datavm.data、vm.props、vm.$refs等,甚至临时调用方法(如vm.handleClick());
  • Vue3:在Console输入vm = document.querySelector('vue-app').__vue_app__._instance,获取根组件实例,通过vm.proxy访问响应式数据(如vm.proxy.message);
  • 注意:Console调试仅用于临时查看,避免在Console中修改敏感数据(如用户token、隐私信息),调试完成后清空Console记录。

二、Vue专属调试:Vue Devtools(组件/响应式数据调试)

Vue Devtools是专为Vue设计的浏览器插件,不仅适用于开发环境,也可用于线上调试,能直观查看组件树、响应式数据、路由信息,快速排查组件相关问题,是Vue开发者的必备调试工具。

1. 线上启用方法(解决“线上无法激活”问题)

默认情况下,Vue Devtools在生产环境(线上)会被禁用,需通过以下方法启用:

  1. 安装Vue Devtools插件(Chrome/Firefox均可,推荐Chrome);

  2. 打开线上Vue项目,按F12打开DevTools,切换到「Vue」面板(若没有,需重启DevTools);

  3. 若面板提示“Vue.js not detected”,按以下步骤操作:

    1. Vue2:在Console输入Vue.config.productionTip = true,刷新页面,即可激活Vue Devtools;
    2. Vue3:在Console输入window.__VUE_DEVTOOLS_GLOBAL_HOOK__.enable=true,刷新页面,激活插件。

2. 核心调试功能(针对性解决Vue问题)

  • 组件树查看:在「Components」面板,可查看整个项目的组件嵌套结构,选中任意组件,右侧可查看该组件的props、data、computed、refs等,还能实时编辑数据(如修改data中的值),查看页面变化,快速定位组件数据异常问题;
  • 响应式数据调试:在「State」面板(Vue3)/「Vuex」面板(Vue2),可查看Pinia/Vuex的全局状态,实时监控状态变化,排查状态更新异常、数据同步问题;
  • 路由调试:在「Router」面板,可查看当前路由、路由参数、路由历史,模拟路由跳转(无需刷新页面),排查路由跳转异常、参数传递问题;
  • 生命周期调试:可查看组件的生命周期钩子执行情况,判断钩子函数是否正常触发,排查生命周期相关的逻辑问题。

三、日志调试:规范日志收集(线上故障可追溯)

线上调试的核心痛点之一是“无法复现场景”,尤其是偶发故障,此时通过日志收集,可记录用户操作链路、错误信息,实现故障追溯,替代杂乱的console.log,同时避免敏感信息泄露。

1. 日志框架选型与配置(Vue项目推荐)

不推荐直接使用console.log(易泄露敏感信息、日志杂乱),建议使用专业日志框架,实现日志分级、环境区分、远程上报,常用框架如下:

  • 轻量首选:loglevel(无依赖、体积极小,支持多环境日志控制,适配Vue2/Vue3,可快速替代console.log);
  • Vue专属:vue-logger-plugin(专为Vue设计,零侵入、开箱即用,支持日志分级、格式化输出,适配组合式API);
  • 大型项目:pino(高性能,支持结构化JSON日志,便于日志分析工具解析,适配高并发场景)。

配置示例(以loglevel为例,Vue3组合式API):

// 1. 安装
// npm install loglevel --save

// 2. 封装日志工具(src/utils/logger.js)
import log from 'loglevel';

// 配置:开发环境显示所有日志,线上环境仅显示错误日志
if (import.meta.env.PROD) {
  log.setLevel('error'); // 线上仅输出error级别日志
} else {
  log.setLevel('debug'); // 开发环境输出所有级别日志
}

// 脱敏处理:隐藏敏感信息(如token、手机号)
export const logger = {
  debug: (msg, data = {}) => log.debug(msg, filterSensitiveData(data)),
  info: (msg, data = {}) => log.info(msg, filterSensitiveData(data)),
  warn: (msg, data = {}) => log.warn(msg, filterSensitiveData(data)),
  error: (msg, data = {}) => log.error(msg, filterSensitiveData(data))
};

// 敏感信息脱敏函数
function filterSensitiveData(data) {
  if (typeof data !== 'object' || data === null) return data;
  const cloneData = JSON.parse(JSON.stringify(data));
  if (cloneData.token) cloneData.token = '***';
  if (cloneData.phone) cloneData.phone = cloneData.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
  return cloneData;
}

2. 日志使用与远程上报

  • 代码中使用:在关键逻辑(如接口请求、按钮点击、异常捕获)处添加日志,记录操作信息和数据; <script setup> `` import { logger } from '@/utils/logger'; `` import axios from 'axios'; ```` const getList = async () => { `` try { `` logger.info('请求列表接口', { url: '/api/list', params: { page: 1 } }); `` const res = await axios.get('/api/list', { params: { page: 1 } }); `` logger.debug('列表接口响应', res.data); `` } catch (err) { `` logger.error('列表接口请求失败', { err: err.message }); `` } `` }; ``</script>

  • 远程上报:将线上错误日志上报至服务器或第三方监控平台(如Sentry),步骤如下:

    • 安装Sentry SDK:npm install @sentry/vue @sentry/vite-plugin --save-dev(Vite项目);
    • 配置vite.config.js,自动生成并上传Source Map; import { defineConfig } from 'vite'; `` import vue from '@vitejs/plugin-vue'; `` import { sentryVitePlugin } from '@sentry/vite-plugin'; ```` export default defineConfig({ `` build: { sourcemap: true }, // 必须开启Source Map `` plugins: [ `` vue(), `` sentryVitePlugin({ `` authToken: '你的Sentry令牌', `` org: '你的Sentry组织', `` project: '你的项目名' `` }) `` ] ``});
    • 线上出现错误时,Sentry会自动收集错误日志、调用栈、设备环境,开发者可通过Sentry后台查看详细信息,快速复现场景、定位问题。

四、接口调试:排查接口异常(线上常见问题)

Vue线上问题多与接口相关(如接口报错、数据返回异常),可通过Chrome DevTools的「Network」面板和接口调试工具,快速排查接口问题,无需修改线上代码。

1. Network面板调试(查看接口详情)

  1. 打开线上项目,按F12进入DevTools,切换到「Network」面板,勾选「XHR/Fetch」(只显示接口请求);

  2. 触发接口请求(如刷新页面、点击按钮),面板会显示所有接口的请求信息,包括请求URL、方法、状态码、请求头、响应数据;

  3. 排查重点:

    1. 状态码:4xx(客户端错误,如参数错误、权限不足)、5xx(服务端错误),点击接口查看「Response」面板,获取错误信息;
    2. 请求头:检查是否携带Token、Cookie等关键信息,是否与后端要求一致;
    3. 响应数据:查看返回的数据是否符合预期,是否存在数据缺失、格式错误等问题;
    4. 请求参数:点击「Payload」面板,查看请求参数是否正确,排查参数传递异常问题。

2. 接口重放与模拟(复现场景)

若接口返回异常,可通过「Network」面板重放接口,修改参数测试,无需修改线上代码:

  1. 在Network面板选中异常接口,右键选择「Replay XHR」,可重放该接口,查看是否为偶发问题;
  2. 若需修改参数测试,右键选择「Edit and Resend」,修改请求参数、请求头,点击「Send」,查看修改后的响应结果,快速定位参数问题;
  3. 补充:可使用Postman、Apifox等工具,复制线上接口的请求信息,模拟接口请求,对比线上响应与本地测试环境的差异,排查环境相关问题。

五、进阶调试:临时修改线上代码(紧急排查)

若需临时修改线上代码(如验证某个逻辑、绕过某个bug),可通过Chrome DevTools的「Overrides」功能,临时替换线上文件,不影响其他用户,调试完成后需立即撤销。

  1. 打开Chrome DevTools,切换到「Sources」面板,点击左侧「Overrides」→ 点击「Select folder for overrides」,选择本地一个空文件夹(用于存储临时修改的文件);
  2. 在「Page」面板找到线上需要修改的文件(如src/views/Home.vue,需开启Source Map),右键选择「Save for overrides」,将文件保存到本地文件夹;
  3. 双击文件,在DevTools中修改代码(如添加日志、修改逻辑),保存后,页面会自动刷新,执行修改后的代码;
  4. 调试完成后,删除本地文件夹中的临时文件,在「Overrides」面板取消勾选「Enable local overrides」,恢复线上原始代码。

六、调试避坑与安全注意事项

  • 禁止线上暴露源码:Source Map配置需谨慎,避免使用source-map(会直接暴露完整源码),优先使用hidden-source-map,仅支持调试但不暴露源码;
  • 杜绝敏感信息泄露:调试时不打印用户token、手机号、隐私数据,日志需做脱敏处理,调试完成后清空Console记录;
  • 不影响线上用户:临时修改线上代码(Overrides功能)仅对当前浏览器生效,不影响其他用户,调试完成后必须撤销修改;
  • 避免过度调试:线上调试以“定位问题”为主,不建议在Console中执行复杂逻辑,避免触发线上异常;
  • 调试后清理痕迹:日志框架在上线前需配置正确的日志级别(线上仅输出error),避免冗余日志占用资源;临时添加的调试代码,上线前必须删除。

七、总结(实操优先级)

Vue线上调试的实操优先级:「Chrome DevTools(Source Map+断点)」→「Vue Devtools(组件/响应式调试)」→「日志收集(远程上报)」→「接口调试(Network)」→「临时修改代码(Overrides)」。

日常线上排查时,优先通过Source Map定位报错,用Vue Devtools排查组件和数据问题,用日志和Network面板追溯故障场景;紧急情况下,可通过Overrides临时修改代码验证逻辑,核心是“安全、无侵入、不影响用户”,快速定位并解决问题。

Vue3 超全复盘!30+前端高频核心知识点(开发+面试全覆盖)

Vue3 作为目前前端项目的主流技术栈,无论是日常业务开发、工程化项目搭建,还是前端面试,都是必考且核心的技术重点

很多开发者长期使用 Vue3 开发,但知识点零散、体系混乱,面试时无法系统作答,开发时也容易写出不规范的代码。

本文系统化复盘 30+ Vue3 高频知识点,涵盖组合式API、响应式原理、生命周期、组件通信、性能优化、新特性、避坑指南七大模块,全部为实战高频考点,结构清晰、干货密集,适合收藏复盘、查漏补缺、面试背诵。


一、Vue3 整体核心优势(面试开篇必答)

相比 Vue2,Vue3 在架构、性能、语法、工程化上全面升级,核心优势集中在 5 点:

  1. 性能大幅提升:重写虚拟 DOM、优化 diff 算法、支持静态提升、预字符化,初始渲染和更新渲染速度更快
  2. 体积更小:全面模块化、按需引入、Tree-Shaking 友好,打包体积大幅压缩
  3. 组合式 API:替代 Options 选项式 API,解决大型项目代码碎片化、逻辑分散问题,支持逻辑抽离与复用
  4. 更强的 TS 支持:源码基于 TS 重写,类型推断完善,大型项目类型约束更严谨
  5. 全新响应式原理:基于 Proxy 替代 Object.defineProperty,解决 Vue2 响应式底层缺陷

二、组合式 API 核心知识点(开发最常用)

组合式 API 是 Vue3 最大的更新,也是日常开发使用率最高的语法,下面汇总高频核心用法与知识点。

1. setup 函数

  • Vue3 组合式 API 的入口函数,组件创建前执行,比生命周期更早
  • 无法使用 this,this 指向 undefined
  • 内部定义的变量、函数,需要 return 后才可在模板中使用(script setup 语法糖无需手动 return)
  • 支持同步写法,不支持 async/await 顶层异步(会阻塞组件渲染)
<script setup>
// 【规范写法】script setup 语法糖
// 无需手动return、无需注册组件,代码极简
let msg = 'Vue3 setup 入门'

function showMsg() {
  console.log(msg)
}
</script>

<template>
  <div>{{ msg }}</div>
</template>

避坑要点:禁止使用顶层 async setup,会导致组件渲染阻塞

<!-- 错误写法:顶层async,阻塞组件挂载 -->
<script setup async>
  const res = await fetch('/api/list')
</script>

2. ref 基础响应式

  • 用于定义基本数据类型响应式数据:String、Number、Boolean、Null、Undefined
  • 底层通过类实例实现响应式,数据默认包裹在 .value
  • 模板中可省略 .value,JS 逻辑中必须手动书写 .value
  • 也可兼容定义对象、数组,但不推荐,性能不如 reactive
<script setup>
import { ref } from 'vue'

// 【正确写法】基础类型使用ref定义响应式
const count = ref(0)
const name = ref('前端复盘')

// JS逻辑中必须通过.value修改值
const add = () => {
  count.value++
}
</script>

<template>
  <div>
    <p>数值:{{ count }}</p>
    <p>名称:{{ name }}</p>
    <button @click="add">自增</button>
  </div>
</template>

错误写法踩坑:基础类型直接定义,无响应式;ref对象直接赋值覆盖响应式

<script setup>
// 错误1:普通变量,非响应式,视图不更新
let num = 10

// 错误2:直接替换ref整个对象,丢失响应
const refNum = ref(0)
refNum = 100 
</script>

3. reactive 响应式对象

  • 专门用于定义引用类型响应式数据:对象、数组、嵌套复杂数据
  • 基于 Proxy 实现,无需 .value,直接访问属性
  • 支持深度响应式,默认递归监听所有嵌套属性
  • 存在有限性:解构会丢失响应式、直接替换对象会丢失响应式
<script setup>
import { reactive } from 'vue'

// 【正确写法】引用类型使用reactive
const userInfo = reactive({
  name: 'Vue3开发者',
  age: 24,
  address: {
    city: '北京'
  }
})

// 直接修改属性,无需.value,深度响应式生效
const changeCity = () => {
  userInfo.address.city = '上海'
}
</script>

<template>
  <div>
    <p>城市:{{ userInfo.address.city }}</p>
    <button @click="changeCity">切换城市</button>
  </div>
</template>

错误写法踩坑:直接替换整个reactive对象、解构赋值,丢失响应式

<script setup>
import { reactive } from 'vue'
const state = reactive({ a: 1, b: 2 })

// 错误1:直接替换整个对象,响应式彻底丢失
// state = reactive({ a: 100 })

// 错误2:直接解构,变量脱离响应式追踪
// const { a } = state
</script>

4. toRefs 解构保留响应式

  • 解决 reactive 对象解构丢失响应式问题
  • 将 reactive 对象的每一个属性转为独立 ref 对象
  • 解构后依然保留双向响应式,是项目高频实用技巧
<script setup>
import { reactive, toRefs } from 'vue'

const state = reactive({
  title: 'Vue3复盘',
  num: 10
})

// 【错误写法】直接解构,丢失响应式,修改不更新视图
// const { title, num } = state

// 【正确写法】toRefs解构,保留完整响应式
const { title, num } = toRefs(state)

const changeTitle = () => {
  title.value = 'Vue3知识点汇总'
}
</script>

<template>
  <div>{{ title }} - {{ num }}</div>
</template>

5. toRef 精准创建属性响应式

  • 单独针对对象某个属性创建响应式引用
  • 适用于只需要监听单个属性、无需解构全部数据的场景
<script setup>
import { reactive, toRef } from 'vue'

const info = reactive({
  a: 1,
  b: 2,
  c: 3
})

// 【正确写法】单独绑定对象属性,保留响应式引用
const a = toRef(info, 'a')

const updateA = () => {
  a.value += 1
}
</script>

<template>
  <div>{{ a }}</div>
</template>

错误写法踩坑:直接赋值属性,属于普通变量,无响应联动

<script setup>
// 错误:只是普通数值拷贝,和原对象无联动
const a = info.a
a++ // 视图不更新,原对象值不变
</script>

6. computed 计算属性

  • 具备缓存机制,依赖不变则不重复计算,优于方法调用
  • 分为只读计算属性、可写计算属性
  • 自动收集依赖,依赖更新自动触发更新
<script setup>
import { ref, computed } from 'vue'

const price = ref(99)
const count = ref(2)

// 【正确1】只读计算属性(业务最常用)
const totalPrice = computed(() => {
  return price.value * count.value
})

// 【正确2】可写计算属性
const doubleCount = computed({
  get() {
    return count.value * 2
  },
  set(val) {
    count.value = val / 2
  }
})
</script>

<template>
  <div>总价:{{ totalPrice }}</div>
</template>

错误写法踩坑:用普通方法替代计算属性,无缓存,频繁重复计算,性能差

<script setup>
// 错误:每次渲染都会执行,无缓存,性能浪费
const getTotal = () => {
  return price.value * count.value
}
</script>

7. watch / watchEffect 监听机制

  • watch:精准监听指定数据,支持新旧值、深度监听、立即执行
  • watchEffect:自动收集依赖,无需手动传入监听源,立即执行、自动响应
  • watch 适合精准监听单一数据,watchEffect 适合多依赖自动监听场景
<script setup>
import { ref, reactive, watch, watchEffect } from 'vue'

const num = ref(0)
const user = reactive({ name: '张三', age: 20 })

// 【正确1】精准监听基础类型
watch(num, (newVal) => {
  console.log('数值更新:', newVal)
})

// 【正确2】监听复杂对象,开启立即执行+深度监听
watch(user, () => {
  console.log('用户信息更新')
}, { immediate: true, deep: true })

// 【正确3】watchEffect自动收集多依赖
watchEffect(() => {
  console.log('自动监听数据:', num.value, user.name)
})
</script>

错误写法踩坑:监听reactive对象不开启deep,嵌套属性更新不触发监听

<script setup>
// 错误:未开启deep,嵌套属性更新无法监听
watch(user, () => {
  console.log('更新')
})
user.age = 25 // 不触发监听回调
</script>

三、Vue3 生命周期知识点

Vue3 生命周期兼容 Vue2 写法,同时提供组合式 API 钩子,核心常用钩子 8 个,面试高频考察执行顺序。

  1. onBeforeCreate / onCreated:组件创建阶段,setup 替代大部分逻辑
  2. onBeforeMount / onMounted:DOM 挂载前后,异步请求、DOM 操作放在 onMounted
  3. onBeforeUpdate / onUpdated:数据更新、DOM 重渲染前后
  4. onBeforeUnmount / onUnmounted:组件销毁前后,用于清除定时器、监听事件、解绑全局监听
<script setup>
import { onMounted, onUpdated, onUnmounted } from 'vue'
let timer = null

// 【正确】DOM挂载后请求接口、初始化数据、开启定时器
onMounted(() => {
  console.log('组件挂载完成')
  timer = setInterval(() => {}, 1000)
})

// 数据更新DOM完成后执行
onUpdated(() => {
  console.log('组件更新完成')
})

// 【正确】组件销毁,清除副作用,防止内存泄漏
onUnmounted(() => {
  clearInterval(timer)
  timer = null
  console.log('组件销毁,清除定时器')
})
</script>

错误写法踩坑:所有逻辑堆在setup顶层、不清除副作用

<script setup>
// 错误1:顶层直接请求,执行时机不可控
// 错误2:不清除定时器,页面销毁内存泄漏
setInterval(() => {}, 1000)
</script>

核心考点:Vue3 取消了 beforeCreate、created,统一在 setup 中编写初始化逻辑;销毁钩子更名(destroyed → unmounted)。


四、Vue3 组件通信全方案(高频业务+面试)

Vue3 废弃了 Vue2 的 childrenchildren、listeners、事件总线等部分 API,提供更规范、更安全的通信方式,全覆盖 8 种通信方案。

1. 父子通信:props / defineProps

父传子核心方案,支持类型校验、默认值、必填校验,Vue3 推荐使用 defineProps 语法糖。

// 子组件 【正确写法】规范校验+默认值
<script setup>
const props = defineProps({
  title: {
    type: String,
    default: '默认标题'
  },
  list: {
    type: Array,
    default: () => [] // 引用类型必须函数返回
  }
})
</script>

// 父组件使用
<Child title="Vue3复盘" :list="[1,2,3]" />

错误写法踩坑:引用类型默认值直接写死,所有组件实例共享数据

<script setup>
// 错误:数组默认值字面量,多组件数据污染
const props = defineProps({
  list: {
    type: Array,
    default: []
  }
})
</script>

2. 子父通信:emit / defineEmits

子组件通过 defineEmits 自定义事件,向上传递数据,替代 Vue2 this.$emit。

// 子组件【正确写法】
<script setup>
// 声明自定义事件
const emit = defineEmits(['sendData'])

const send = () => {
  emit('sendData', '子组件传递的数据')
}
</script>

// 父组件接收
<Child @sendData="handleData" />
<script setup>
const handleData = (res) => {
  console.log('接收子组件数据:', res)
}
</script>

错误写法踩坑:script setup 中直接使用 this.$emit(Vue3语法失效)

<script setup>
// 错误:setup无this,直接报错
this.$emit('sendData', '测试')
</script>

3. 双向绑定:defineModel(Vue3.4+ 新特性)

极简实现组件双向绑定,无需 props+emit 繁琐写法,封装弹窗、输入框组件必备。

// 子组件【正确写法】Vue3.4+ defineModel 极简双向绑定
<script setup>
const modelValue = defineModel()
</script>

<template>
  <input v-model="modelValue" placeholder="双向绑定输入" />
</template>

// 父组件使用
<script setup>
const inputVal = ref('')
</script>
<template>
  <Child v-model="inputVal" />
</template>

旧写法(繁琐不推荐) :传统props+emit冗余实现双向绑定

// 老旧冗余写法,现已废弃
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const change = (val) => {
  emit('update:modelValue', val)
}

4. 祖先后代通信:provide / inject

跨多层级组件传值,无需逐层透传,适合全局配置、主题、权限、用户信息透传。

// 【正确】祖先组件注入数据
<script setup>
import { provide, ref } from 'vue'
// 传递响应式数据,后代可联动更新
const theme = ref('dark')
provide('theme', theme)
provide('userName', '超级管理员')
</script>

// 【正确】任意后代组件接收
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const userName = inject('userName')
</script>

错误写法踩坑:传递普通静态值,后代无法响应更新

<script setup>
// 错误:传递普通字符串,非响应式,修改不联动
provide('theme', 'light')
</script>

5. 组件实例获取:defineExpose

Vue3 组件默认关闭实例暴露,父组件想要调用子组件方法、获取子组件数据,必须通过 defineExpose 主动暴露。

// 子组件【正确写法】主动暴露实例和方法
<script setup>
const msg = '子组件数据'
const childFn = () => console.log('执行子组件方法')

// 主动暴露,父组件才可调用
defineExpose({ msg, childFn })
</script>

// 父组件【正确写法】
<script setup>
import { ref } from 'vue'
const childRef = ref(null)

const callChild = () => {
  childRef.value.childFn()
}
</script>

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

错误写法踩坑:不写defineExpose,父组件获取不到子组件数据和方法

<script setup>
// 子组件未暴露任何内容
const childFn = () => {}
// 父组件调用直接报错
// childRef.value.childFn() 【undefined】
</script>

6. 全局状态通信:Pinia

Vue3 官方替代 Vuex 的状态管理库,轻量化、简洁、模块化、无嵌套,支持 TS、自动持久化,是项目全局状态共享首选方案。

// 1. 新建 store/user.js 状态仓库
import { defineStore } from 'pinia'

// 定义用户全局仓库
export const useUserStore = defineStore('user', {
  // 状态
  state: () => ({
    userName: '',
    token: '',
    userId: ''
  }),
  // 计算属性
  getters: {
    isLogin: (state) => !!state.token
  },
  // 同步/异步方法
  actions: {
    // 存储用户信息
    setUserInfo(data) {
      this.userName = data.userName
      this.token = data.token
      this.userId = data.userId
    },
    // 清空用户信息
    clearUserInfo() {
      this.$reset()
    }
  }
})
// 2. 组件内使用 Pinia 状态
<script setup>
import { useUserStore } from '@/store/user'
// 初始化仓库
const userStore = useUserStore()

// 赋值修改全局状态
const setUser = () => {
  userStore.setUserInfo({
    userName: 'Vue3开发者',
    token: 'xxxx-xxxx-xxxx',
    userId: '10001'
  })
}

// 清空状态
const clearUser = () => {
  userStore.clearUserInfo()
}
</script>

<template>
  <div>
    <p>用户名:{{ userStore.userName }}</p>
    <button @click="setUser">登录赋值</button>
    <button @click="clearUser">清空信息</button>
  </div>
</template>

7. 插槽通信:slot / 作用域插槽

默认插槽、具名插槽实现内容分发,作用域插槽实现子传父数据渲染,高阶组件封装必备。

// 子组件
<template>
  <!-- 作用域插槽向外传递数据 -->
  <slot :msg="hello vue3"></slot>
</template>

// 父组件
<template>
  <Child v-slot="scope">
    {{ scope.msg }}
  </Child>
</template>

8. 兄弟组件通信:Pinia / 自定义事件

Vue3 不推荐事件总线,统一使用 Pinia 实现兄弟组件状态共享,稳定易维护。


五、Vue3 响应式原理核心考点(面试重中之重)

1. 底层原理 Proxy

Vue3 使用Proxy + Reflect 实现响应式,替代 Vue2 Object.defineProperty,解决大量底层缺陷。

2. Proxy 对比 defineProperty 优势

  • 可监听数组新增、删除、下标修改、长度修改
  • 可监听对象新增、删除属性
  • 支持批量拦截、更完善的拦截能力
  • 无需递归遍历初始对象,性能更优

3. 三大核心机制

  • 依赖收集:数据读取时收集当前组件渲染副作用
  • 依赖追踪:数据变更触发对应的更新函数
  • 派发更新:通知视图更新、执行监听与计算属性回调

4. 响应式丢失常见场景

  • reactive对象直接解构:丢失响应式
  • 直接替换reactive对象:丢失响应式
  • 数组下标/长度直接修改:部分场景不更新视图
  • 普通函数接收响应式数据:丢失响应式绑定
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({ a: 1, b: 2 })

// ========== 错误写法(全部踩坑)==========
// 坑1:直接解构,丢失响应式
// const { a } = state

// 坑2:直接替换整个对象,响应式销毁
// state = reactive({ a: 100 })

// 坑3:数组下标直接修改,部分场景不更新视图
// const arr = reactive([1,2,3])
// arr[0] = 99

// ========== 正确写法(规范稳定)==========
const { a, b } = toRefs(state)
a.value = 99 // 响应式正常更新

六、Vue3 编译与性能优化知识点

1. 虚拟 DOM 重写

Vue3 重构虚拟 DOM,优化 diff 算法,对比层级更精准、补丁更少、更新更快。

2. 静态提升

编译阶段将静态不变节点提升到渲染函数外部,避免每次渲染重新创建 VNode,大幅提升性能。

3. 预字符化

连续静态文本合并为常量字符串,减少虚拟 DOM 节点数量,降低内存占用。

4. 缓存事件处理函数

Vue3 自动缓存模板事件函数,避免每次更新生成新函数,减少不必要的 diff 更新。

5. 按需引入 Tree-Shaking

Vue3 全量模块化导出,未使用的 API 可被打包工具剔除,大幅缩减打包体积。


七、Vue3 高频实用新特性知识点

  1. SFC 语法糖升级:script setup 语法,代码更简洁、无需手动 return、自动注册组件,简化组件开发逻辑
  2. CSS 变量注入:v-bind() 可在 CSS 中使用 JS 变量,实现动态样式、主题切换、动态尺寸等高阶样式需求
<script setup>  
    import { ref } from 'vue'  
    // 定义JS动态变量  
    const textColor = ref('#4096ff') 
</script>  
<template>  
    <div class="box">Vue3动态样式</div> 
</template>  
<style scoped>  
.box { 
    color: v-bind(textColor);  
}  
</style>

错误写法踩坑:CSS 无法直接读取 JS 变量,不使用 v-bind 绑定,动态样式不生效

<style scoped>  
/* 错误:无法识别JS变量textColor */ 
.box {  
    color: textColor;  
}  
</style>
  1. 多根节点支持:Vue3 摒弃 Vue2 唯一根节点限制,默认支持 Fragment 虚拟片段,减少多余 DOM 层级,精简页面结构
  2. Teleport 传送门:脱离当前组件DOM层级,将节点挂载到任意指定DOM位置,完美解决弹窗、遮罩、悬浮层层级穿透问题
  3. Suspense 异步兜底:内置异步组件加载兜底方案,无需手动写loading状态,优化异步组件加载体验
  4. defineProps 默认值写法优化:Vue3.3+ 提供 withDefaults 语法糖,完美支持TS类型推导,简洁规范设置props默认值
  5. 自定义指令生命周期更新:Vue3 指令钩子与组件生命周期对齐,废弃Vue2旧钩子,逻辑更统一,避免执行异常

八、Vue3 开发高频避坑知识点

  • reactive 不支持基础类型,基础类型必须用 ref
  • reactive 解构直接丢失响应式,必须配合 toRefs
  • setup 中无 this,无法使用 Vue2 原型方法
  • 组件默认不暴露实例,必须 defineExpose 才可被父组件调用
  • watch 监听 reactive 对象必须开启 deep 深度监听
  • 异步请求写在 onMounted,不要写在 setup 同步顶层大量逻辑
  • 定时器、事件监听必须在 onUnmounted 中清除,防止内存泄漏
  • script setup 中组件自动注册,但全局组件仍需全局注册

九、知识点总结

本次复盘汇总的 30+ Vue3 核心知识点,覆盖:

  • 基础语法:ref / reactive / computed / watch
  • 生命周期与组件执行机制
  • 八大组件通信方案
  • Proxy 响应式底层原理
  • 编译优化与性能提升机制
  • 高频新特性与实战避坑指南

这些知识点既是日常开发必备基础,也是面试高频核心考点,熟练掌握可以彻底打通 Vue3 知识体系,告别只会写业务不懂原理、知识点零散的问题。

Vue3 defineProps使用指南

defineProps是Vue3组合式API( < script setup > )中专用来声明组件接受父组件传值的宏函数,无须导入,直接使用。他的核心:声明子组件要接收的props、定义类型校验、设置默认值、必填项。

一、基础用法(最简单)

直接声明props名称数组,适合简单场景:

<!-- 子组件 Child.vue --> 
<script setup> 
// 基础用法:只声明名称 
defineProps(['title', 'count']) 
</script> 
<template>
    <div>{{ title }}</div>
    <div>{{ count }}</div>
</template>

父组件使用

<!-- 父组件 parent.vue -->
<Child title="我是标题" :cout="10" />

二、带校验的用法(推荐)

可以指定类型、必填、默认值,开发中很常见

<script setup> 
defineProps({ 
    // 基础类型校验 String/Number/Boolean/Array/Object/Function 
    title: { 
        type: String, 
        required: true, // 必填项 
        default: '默认标题' // 默认值 
    }, 
    count: { 
        type: Number, 
        default: 0 
    }, 
    // 多个可能的类型 
    id: [String, Number], 
    // 自定义校验函数 
    status: { 
        validator(value) { 
            // 必须是这几个值之一 
            return ['success', 'error', 'warning'].includes(value) 
        }
    } 
}) </script>

三、TS类型写法(Vue3+TypeScript)

如果用TS,推荐泛型写法,类型更安全

<script setup>
import {widthDefault} from "vue"
// 定义接口(推荐)
interface IProps {
    title: string
    count?: number  //可选
    list?: string[]
}

// 泛型+默认值
const props = withDefault(defineProps<IProps>(), {
    count: 0,
    list: ()=>[]
});
</script>

四、获取和使用props变量

widthDefault或者defineProps都会返回一个响应式对象,可以接收并使用

<script setup>
import { toRefs } from "vue"
// 接收 props 对象 
const props = defineProps(['title', 'count']);
// 1.直接使用props.xxx方式使用
console.log(props.title) 
console.log(props.count)

// 2.通过使用toRefs的方式解构props
// 注意不能直接结构props, 会丢失响应式
const {title, count} = toRefs(props);
console.log(title.value)
console.log(count.value)
</script>

TinyRobot Bubble:为 AI 对话而生的 Vue 3 消息气泡组件

本文由云软件体验技术团队胡靖原创。

在 AI 应用里,消息气泡看似只是 UI 的一小块,真正落地时却会快速变复杂:流式输出、Markdown、图片、多模态内容、推理过程、工具调用、消息分组、状态折叠、角色样式、自动滚动……这些能力如果都从零实现,往往会让业务代码被展示逻辑淹没。

TinyRobot 的 Bubble 组件正是为这个场景设计的。它不是一个简单的“文本气泡”,而是一套面向 AI 对话界面的消息展示系统,内置 BubbleBubbleListBubbleProvider 三个核心能力,让开发者可以从单条消息展示平滑扩展到完整对话流。

1.png

一行代码,展示一条 AI 消息

最基础的用法非常直接:

<template>
  <tr-bubble role="assistant" content="你好,我是 TinyRobot,可以帮助你快速构建 AI 对话界面。" placement="start" />
</template>

<script setup lang="ts">
import { TrBubble } from "@opentiny/tiny-robot";
</script>

Bubble 支持 placement 控制左右位置,支持 avatar 注入头像组件,也支持通过 CSS 变量调整背景、字号、圆角、宽度等视觉细节。对业务开发来说,这意味着你可以先快速搭出可用界面,再按产品设计逐步定制样式。

为流式输出准备的响应式内容

AI 回复通常不是一次性返回,而是逐 token、逐片段输出。Bubble 的 content 是响应式的,只要持续更新内容,组件就能自然呈现流式效果:

<template>
  <tr-bubble :content="streamContent" :avatar="aiAvatar" />
</template>

<script setup lang="ts">
import { TrBubble } from "@opentiny/tiny-robot";
import { IconAi } from "@opentiny/tiny-robot-svgs";
import { h, ref } from "vue";

const aiAvatar = h(IconAi, { style: { fontSize: "32px" } });
const streamContent = ref("");

async function startStream() {
  const text = "这是一段正在生成中的 AI 回复。";
  streamContent.value = "";

  for (const char of text) {
    streamContent.value += char;
    await new Promise((resolve) => setTimeout(resolve, 80));
  }
}
</script>

这类设计非常适合接入 SSE、Fetch Stream 或 TinyRobot Kit 的消息管理能力。展示层只关心消息对象如何变化,不需要把流式渲染逻辑塞进组件内部。

2.gif

不止文本:图片、Markdown、推理和工具调用

Bubble 的内容模型兼容常见的大模型消息结构。content 可以是字符串,也可以是数组内容项,例如图片:

<tr-bubble
  :content="[
    { type: 'text', text: '这是一张生成结果:' },
    { type: 'image_url', image_url: { url: imageUrl } },
  ]"
  content-render-mode="split"
/>

当内容项中出现 type: 'image_url' 时,Bubble 会自动命中内置图片渲染器。通过 contentRenderMode,可以选择把图文渲染在同一个气泡框内,或拆成多个独立 box。

对更复杂的 AI 模型输出,Bubble 也提供了内置渲染器:

  • Text:默认文本渲染
  • Image:图片内容渲染
  • Markdown:Markdown 内容渲染
  • Loading:加载状态渲染
  • Reasoning:推理过程渲染
  • Tools / Tool:工具调用渲染
  • ToolRole:tool 角色消息渲染

例如模型返回推理内容时,可以直接使用 reasoning_content

<tr-bubble :content="answer" :reasoning_content="reasoningContent" :state="{ thinking: false, open: true }" />

工具调用也可以用 OpenAI 风格的 tool_calls 结构表达:

const message = {
  role: "assistant",
  content: "我来查询天气。",
  tool_calls: [
    {
      id: "call_0",
      type: "function",
      function: {
        name: "get_weather",
        arguments: '{"city":"深圳"}',
      },
    },
  ],
  state: {
    toolCall: {
      call_0: { status: "running", open: true },
    },
  },
};

这让 Bubble 很适合构建 Agent、Copilot、企业知识库助手等需要展示“模型正在做什么”的产品。

3.gif

BubbleList:从单条气泡到完整对话流

实际业务不会只展示一条消息。BubbleList 接收 messages 数组,并通过 roleConfigs 统一配置不同角色的头像、位置、形状和隐藏策略:

<template>
  <tr-bubble-list :messages="messages" :role-configs="roleConfigs" auto-scroll />
</template>

<script setup lang="ts">
import type { BubbleListProps, BubbleRoleConfig } from "@opentiny/tiny-robot";
import { TrBubbleList } from "@opentiny/tiny-robot";

const messages: BubbleListProps["messages"] = [
  { role: "user", content: "帮我总结这份文档" },
  { role: "assistant", content: "可以,请上传文档。" },
];

const roleConfigs: Record<string, BubbleRoleConfig> = {
  user: { placement: "end" },
  assistant: { placement: "start" },
};
</script>

BubbleList 默认使用 divider 分组策略,以 user 作为分割点:用户消息单独成组,后续非用户消息合并为同一次回答。它也支持 consecutive 连续角色分组,或传入自定义分组函数。

这种默认策略很适合 AI 聊天结构:一次完整回答通常以 assistant 开始、以 assistant 结束,中间可能穿插一条或多条 tool 结果。

User
└─ 帮我查一下这个工单的 SLA 风险

Assistant 回答块
├─ assistant:我先查询工单详情,并发起 tool call
├─ tool:返回工单详情
├─ tool:返回 SLA 规则
└─ assistant:根据工具结果给出风险判断

User
└─ 那应该怎么处理?

Assistant 回答块
├─ assistant:我继续检查处理人和审批状态
├─ tool:返回处理人信息
└─ assistant:给出处理建议和注意事项

autoScroll 也针对聊天场景做了处理:当用户发送新消息时,列表会平滑滚动到底部;当 AI 内容持续更新时,只有在用户接近底部时才自动跟随,避免打断用户查看历史内容。

渲染器架构:扩展复杂内容,而不是重写组件

Bubble 最值得开发者关注的是它的渲染器机制。组件将渲染拆成两层:

  • Box 渲染器:控制气泡外层容器
  • Content 渲染器:控制具体内容,如文本、图片、Markdown、工具调用

通过 BubbleProvider,可以在组件树内统一配置匹配规则:

<tr-bubble-provider :content-renderer-matches="contentRendererMatches">
  <tr-bubble-list :messages="messages" />
</tr-bubble-provider>
import { BubbleRendererMatchPriority, type BubbleContentRendererMatch } from "@opentiny/tiny-robot";
import { markRaw } from "vue";
import SchemaCardRenderer from "./SchemaCardRenderer.vue";

const contentRendererMatches: BubbleContentRendererMatch[] = [
  {
    find: (_, content) => content.type === "schema_card",
    renderer: markRaw(SchemaCardRenderer),
    priority: BubbleRendererMatchPriority.CONTENT,
  },
];

这套机制让业务可以把订单卡片、审批卡片、知识库引用、图表结果等结构化内容接入 Bubble,而不用 fork 组件或在消息列表里写大量条件判断。

适合企业 AI 应用的状态边界

Bubble 的消息类型中包含 state 字段,专门用于存放 UI 状态,例如推理过程是否展开、工具调用详情是否展开、点赞状态等。组件通过 state-change 事件把状态变更抛给外层。

这种设计的好处是:消息内容仍然保持接近模型返回结构,UI 状态不会污染真正要发给模型或持久化的业务字段。

总结

TinyRobot Bubble 的价值不只是“好看的气泡”,而是把 AI 对话界面里高频、复杂、容易重复造轮子的展示能力沉淀成了一套可组合系统:

  • 单条消息用 Bubble
  • 完整对话流用 BubbleList
  • 全局渲染扩展用 BubbleProvider
  • 文本、图片、Markdown、推理、工具调用都有内置支持
  • 角色、分组、自动滚动、插槽、CSS 变量和 TypeScript 类型一并覆盖

如果你正在用 Vue 3 构建 AI Chat、Agent 控制台、企业知识库助手或 Copilot 类产品,TinyRobot Bubble 可以帮你把注意力从“消息怎么画”转移到“AI 能为用户完成什么”。

关于 OpenTiny NEXT

OpenTiny NEXT 是一套企业智能前端开发解决方案,以生成式 UI 和 WebMCP 两大核心技术为基础,对现有传统的 TinyVue 组件库、TinyEngine 低代码引擎等产品进行智能化升级,构建出面向 Agent 应用的前端 NEXT-SDKs、AI Extension、TinyRobot智能助手、GenUI等新产品,实现AI理解用户意图自主完成任务,加速企业应用的智能化改造。

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

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~如果你有任何问题,欢迎在评论区留言交流!

手写 React 对比 VuReact 编译:真正省下来的是维护成本

📢 前言

很多人讨论 Vue 转 React,第一反应总是“能不能转”“转得快不快”“性能差多少”。

但如果你真的做过迁移,或者真的在 React 里维护过一批复杂组件,你很快会发现,最贵的往往不是第一次把组件写出来,而是之后每一次修改、交接、重构、补功能时,你还要不要重新审一遍 useCallbackuseMemo、依赖数组、事件回调和样式隔离。

所以这篇文章不讨论跑分,也不讨论玄学优化。我只想回答一个更实际的问题:

同一个组件,如果你手写 React,需要亲自维护的东西,是不是明显比“用 Vue 写输入,再交给 VuReact 编译”更多?

我的结论是:是,而且差距不小。VuReact 真正省下来的,不只是迁移动作本身,而是组件进入长期维护期之后,那些原本要由开发者脑补、手填、反复确认的成本。

比较口径说明

为了避免这篇文章变成情绪化宣传,我先把比较口径说清楚。

本文不比较运行时 benchmark,不比较“谁更现代”,也不假装手写 React 只有一种写法。这里比较的是典型工程实现下的维护成本,维度固定为:接口、回调、依赖、样板代码、样式隔离、运行时纯度。

维度 手写 React VuReact 编译路线
props 类型声明 需要手动设计和维护 defineProps / defineEmits 可映射为 TS 类型
事件回调 wiring 需要手动把事件改成 onXxx 编译阶段自动映射
Hook 依赖维护 需要开发者自己判断和补齐 编译阶段自动分析、自动注入
对象/数组 memo 判断 需要自己决定要不要包 useMemo 只对可分析的响应式表达式做优化
样式隔离处理 需要自己选方案并维护一致性 scoped 可直接落成带作用域标识的 CSS
最终产物纯度 取决于你的实现方式 输出就是纯 React,不带 Vue 运行时

也就是说,这篇文章不是在说“手写 React 不好”,而是在说:如果同样的业务目标可以用 Vue 输入 + VuReact 编译完成,那么你本来需要自己承担的维护义务,会少很多。

主证据样本:同一个组件,三种维护方式

我先拿一个综合样本来说话。这个样本不是极端 demo,而是很像真实业务组件:有 props、有 emits、有 ref、有 computed、有顶层箭头函数、有对象方法,还有 scoped 样式。

先看 Vue 输入。你会发现它本质上就是一个很正常的 Vue 3 组件,没有为了“迁移”刻意写成奇怪样子。

<template>
  <section class="counter-card">
    <h1>{{ props.title }}</h1>
    <h2>VuReact + Vue = React ({{ count }})</h2>
    <p>{{ title }}</p>
    <button @click="increment">+1</button>
    <button @click="methods.decrease">-1</button>
  </section>
</template>

<script setup lang="ts">
// @vr-name: HelloWorld
import { computed, ref, watch } from 'vue';

const props = defineProps<{ title?: string }>();
const emits = defineEmits<{ (e: 'update', value: number): void }>();

const step = ref(1);
const count = ref(0);
const title = computed(() => `阶数:x${step.value}`);

const increment = () => {
  count.value += step.value;
  emits('update', count.value);
};

const methods = {
  decrease() {
    count.value -= step.value;
    emits('update', count.value);
  },
};

watch(count, (newVal) => {
  step.value = Math.floor(newVal / 10) || 1;
});
</script>

<style scoped>
.counter-card { border: 1px solid #ddd; padding: 12px; }
</style>

如果这段逻辑让你手写成 React,一个很典型的等价实现,大概会长这样。注意,这不是“唯一正确写法”,而是一个工程上完全合理、也是多数团队都会接受的版本。

import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import './HelloWorld.css';

type IHelloWorldProps = {
  title?: string;
  onUpdate?: (value: number) => void;
};

const HelloWorld = memo((props: IHelloWorldProps) => {
  const [step, setStep] = useState(1);
  const [count, setCount] = useState(0);

  const title = useMemo(() => `阶数:x${step}`, [step]);

  const increment = useCallback(() => {
    setCount((prev) => {
      const next = prev + step;
      props.onUpdate?.(next);
      return next;
    });
  }, [step, props.onUpdate]);

  const methods = useMemo(
    () => ({
      decrease() {
        setCount((prev) => {
          const next = prev - step;
          props.onUpdate?.(next);
          return next;
        });
      },
    }),
    [step, props.onUpdate],
  );

  useEffect(() => {
    setStep(Math.floor(count / 10) || 1);
  }, [count]);

  return (
    <section className="counter-card">
      <h1>{props.title}</h1>
      <h2>VuReact + Vue = React ({count})</h2>
      <p>{title}</p>
      <button onClick={increment}>+1</button>
      <button onClick={methods.decrease}>-1</button>
    </section>
  );
});

再看 VuReact 的编译产物。这里最关键的不是“它也能跑”,而是它并没有牺牲 React 工程质量。你在 React 里想要的 memouseComputed/useVRefuseCallbackuseMemo、类型接口、样式作用域,它都完整落下来了。

import { useComputed, useVRef, useWatch } from '@vureact/runtime-core';
import { memo, useCallback, useMemo } from 'react';
import './HelloWorld-ebf8d8dc.css';

export type IHelloWorldProps = {
  title?: string;
} & {
  onUpdate?: (value: number) => void;
};

const HelloWorld = memo((props: IHelloWorldProps) => {
  const step = useVRef(1);
  const count = useVRef(0);
  const title = useComputed(() => `阶数:x${step.value}`);

  const increment = useCallback(() => {
    count.value += step.value;
    props.onUpdate?.(count.value);
  }, [count.value, step.value, props.onUpdate]);

  const methods = useMemo(
    () => ({
      decrease() {
        count.value -= step.value;
        props.onUpdate?.(count.value);
      },
    }),
    [count.value, step.value, props.onUpdate],
  );

  useWatch(count, (newVal) => {
    step.value = Math.floor(newVal / 10) || 1;
  });
});

这时候真正值得看的,不是“哪段代码更短”,而是“哪些维护动作必须由人来做”。按上面这个样本的可见代码统计:

指标 手写 React Vue 输入 + VuReact
显式优化 API 数量 5 处:memo、2 处 useMemouseCallbackuseEffect 0 处由开发者手写
需要手填的依赖数组项数量 6 项 0 项
与稳定性相关的样板代码行数 约 18 行 0 行由开发者额外维护
需要开发者主动判断的优化点数量 至少 5 个 0 个优化判断点

这个表的意义很直接:VuReact 不是帮你“少写一点 React 语法”,而是帮你少承担一整套组件级维护义务。你不用亲自决定标题该不该 useMemo,不用亲自判断回调依赖要不要补 onUpdate,也不用在每次改业务时重新审一遍数组是不是还正确。

次证据样本:连 slot 到 children 的接口翻译,也会更顺

如果只聊 Hook,你可能会以为这件事只是“少写几个依赖数组”。其实不是。组件接口设计本身,也会因为 VuReact 变得更顺。

以插槽为例,Vue 里的默认插槽会自然映射成 React 的 children,作用域插槽会映射成带参数的函数 children。也就是说,VuReact 帮你省掉的,不只是底层优化,还有内容分发接口的手工翻译成本。

例如:

<slot></slot> 会直接落成 props.children

<slot :item="item" :index="i"></slot> 会落成 props.children?.({ item, index })

这件事看起来小,实际在大型组件库里特别重要。因为你少做的不是一行改写,而是少做一次“我要把 Vue 的内容分发机制手工翻成 React 接口”的设计工作。对于需要交给别人继续维护的组件,这种接口自然度非常值钱。

工程上更关键的一点:产物是纯 React,不是套壳

很多“转换工具”最让人不放心的地方,不在于能不能跑,而在于它最后到底给你留下了什么。

VuReact 在这一点上的边界其实很清楚:官方文档明确强调,编译产物最终为纯 React 应用,不依赖 Vue 运行时,也不是在 React 中嵌入 Vue 容器的套壳方案。

这句话为什么重要?因为它直接决定了后续维护体验。

如果最终产物是双运行时桥接,短期也许能演示,但长期一定会出现调试复杂、性能归因困难、团队协作断层的问题。可如果最终产物就是标准 React 代码,那它就能直接进入你现有的 React 工具链、code review 流程和长期演进路径。

这也是为什么我更愿意用官网那四个词来概括 VuReact:语义感知、渐进迁移、约定驱动、完整特性适配。 它不是在做“表面可运行”,而是在做“可进入工程维护周期的 React 产物”。

为什么这对团队比对个人更重要

个人开发者感受到的是轻松,团队感受到的则是确定性。

对 code review 来说,少一些手工 memo 和依赖数组,意味着 review 的注意力可以更多放回业务本身,而不是反复检查“这里是不是漏依赖了”。对交接来说,新同事看到的是更稳定的输入约定和更标准的输出产物,而不是一堆高度依赖原作者经验的 React 小技巧。

对重构来说,成本差异更明显。手写 React 组件经常让人不敢轻动,因为你一改业务结构,就可能牵动 useMemouseCallbackuseEffect 的依赖关系。VuReact 让这类稳定性工作前移到编译阶段,本质上是在降低重构的心理门槛。

对迁移路线也是一样。你当然可以手写一个组件、十个组件,但当项目规模上来之后,真正难的不是有没有人会写 React,而是有没有办法把大量“手工判断”变成稳定流程。VuReact 的价值,恰恰就在这里。

下一步怎么验证

如果你想判断这是不是适合你的路线,最好的方法不是继续看宣传语,而是直接去看真实产物。

先看官网的 语义编译对照 和 “为什么选 VuReact”,确认它是不是你认同的工程思路;再看 GitHub 和在线演示,判断编译后的 React 项目是不是你愿意接手维护的样子;如果还想继续深挖,可以再读我前面写过的那篇 “证据链” 文章,专门看 Hook 和依赖数组那一层的负担差异。

官网GitHub在线演示(CRM)在线演示(Customer Support Hub)

💬 写在最后

VuReact 的初心一直没有变——让你用熟悉的 Vue 编写 React,同时让项目平滑迁移到 React 生态,降低迁移成本,保留开发体验

它是一款面向 Vue 转 React 编译工具,它能将 Vue 3 代码编译为标准、可维护的纯 React 。

🌐 Github:github.com/vureact-js/… 📃 官方文档:vureact.top

✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!Github 仓库点亮 Star ⭐!

前端优化实战指南(工程化/首屏/懒加载/Next.js等)

构建优化是前端优化的基础,也是性价比最高的优化方向——无需大量修改业务代码,就能显著降低打包体积、提升构建速度,适配不同环境的部署需求,同时适配 React、Vue2、Vue3 等主流框架。


一、工程化 & 构建优化(Webpack / Vite)

1.1 Webpack 优化(主流项目实战,适配 React/Vue2/Vue3)

1.1.1 减小打包体积(核心:tree-shaking + 代码分割 + 依赖优化)

  • 开启 tree-shaking:仅打包被使用的代码,剔除死代码。

    • 配置:mode: "production"(默认开启),配合 package.json"sideEffects": false(需确认第三方依赖无副作用,若有则单独配置,如 ["*.css", "*.less", "*.scss"])。
    • 注意:仅对 ES6 模块(import/export)有效,CommonJS 模块(require)无法触发 tree-shaking,需避免混用模块规范;Vue2 项目需确保使用 vue-loader@15+ 版本,React 项目需避免使用 require 引入组件/工具。
  • 代码分割(Code Splitting):将代码拆分为多个 chunk,避免单文件过大,实现按需加载。

    • 路由分割

      • React 项目:使用 React.lazy + Suspense(函数组件),配合 react-router-dom 实现路由拆分,Suspense 需配置 fallback(加载占位),避免页面空白;类组件可使用 loadable-components 替代。

        // React 路由分割示例
        import { Suspense, lazy } from 'react';
        import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
        
        const Home = lazy(() => import('./pages/Home'));
        const About = lazy(() => import('./pages/About'));
        
        function App() {
          return (
            <Router>
              <Suspense fallback={<div>加载中...</div>}>
                <Routes>
                  <Route path="/" element={<Home />} />
                  <Route path="/about" element={<About />} />
                </Routes>
              </Suspense>
            </Router>
          );
        }
        
      • Vue2 项目:使用 vue-routercomponent: () => import('xxx'),配合 webpackChunkName 自定义 chunk 名称,便于调试。

        // Vue2 路由分割示例(vue-router@3.x)
        const router = new VueRouter({
          routes: [
            {
              path: '/',
              name: 'Home',
              component: () => import(/* webpackChunkName: "home" */ './views/Home.vue')
            },
            {
              path: '/about',
              name: 'About',
              component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
            }
          ]
        });
        
      • Vue3 项目:与 Vue2 用法一致,适配 vue-router@4.x,可结合 setup 语法使用,无额外配置差异。

    • 公共依赖分割splitChunks 配置,将第三方依赖(如 react、react-dom、vue、vue-router、axios)与业务代码分离,单独打包为 vendor chunk,利用浏览器缓存复用。

      // webpack.config.js
      optimization: {
        splitChunks: {
          chunks: 'all',
          cacheGroups: {
            vendor: {
              test: /[\\/]node_modules[\\/]/,
              name: 'vendors',
              priority: 10,
              reuseExistingChunk: true
            },
            common: {
              minSize: 30000,
              minChunks: 2,
              priority: 5,
              reuseExistingChunk: true
            }
          }
        }
      }
      
  • 依赖优化:剔除无用依赖 + 替换轻量依赖

    • 使用 webpack-bundle-analyzer 分析打包体积,找到体积过大的依赖。

    • 替换方案:moment.jsday.js(体积缩小 80%+)、lodashlodash-es(支持 tree-shaking)、jquery → 原生 DOM / 轻量库。

    • 按需引入:

      • React 生态:antd、Material-UI 等 UI 库,使用 babel-plugin-import 实现组件和样式的按需加载。
      • Vue2 生态:element-ui 使用 babel-plugin-import 按需引入。
      • Vue3 生态:element-plusant-design-vue@4+ 支持 babel-plugin-import 按需引入,也可通过 setup 语法自动按需引入组件。
      // .babelrc(React + antd 按需引入)
      {
        "plugins": [
          ["import", {
            "libraryName": "antd",
            "libraryDirectory": "es",
            "style": "css"
          }]
        ]
      }
      
      // .babelrc(Vue2 + element-ui 按需引入)
      {
        "plugins": [
          ["import", {
            "libraryName": "element-ui",
            "libraryDirectory": "lib",
            "style": true
          }]
        ]
      }
      
  • 资源压缩

    • JS 压缩:production 模式默认使用 TerserPlugin,可配置 parallel: true 开启多线程压缩。

    • CSS 压缩:使用 mini-css-extract-plugin 提取 CSS 为单独文件,配合 css-minimizer-webpack-plugin 压缩 CSS。

    • 图片压缩:使用 image-webpack-loader 压缩图片,配置 limit 限制小图片转为 base64(减少 HTTP 请求)。

      // module.rules 中配置
      {
        test: /\.(png|jpe?g|gif|svg)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
              name: 'static/img/[name].[hash:8].[ext]',
              esModule: false
            }
          },
          'image-webpack-loader'
        ]
      }
      

1.1.2 提升构建速度

  • 多线程构建:使用 thread-loader 将耗时的 loader(如 babel-loaderts-loader)放入单独线程。

    // React 项目配置
    {
      test: /\.(js|jsx|ts|tsx)$/,
      exclude: /node_modules/,
      use: [
        'thread-loader',
        {
          loader: 'babel-loader',
          options: {
            cacheDirectory: true
          }
        }
      ]
    }
    
    // Vue 项目配置(thread-loader 放在 vue-loader 之前)
    {
      test: /\.vue$/,
      exclude: /node_modules/,
      use: [
        'thread-loader',
        'vue-loader'
      ]
    }
    
  • 缓存优化:开启 loader 缓存(cacheDirectory)和 webpack 持久化缓存(cache: { type: 'filesystem' }),避免每次构建都重新编译。

  • 缩小构建范围exclude 排除 node_modulesdist 等无需编译的目录;include 明确指定需要编译的目录(如 src)。

  • 替换构建工具:若项目体积较大,可考虑将 Webpack 替换为 Vite(基于 ES Module,冷启动速度提升 10 倍+)。Vue3 项目优先使用 Vite,React 项目可使用 @vitejs/plugin-react 适配。

1.2 Vite 优化(新兴项目首选,适配 React/Vue2/Vue3)

Vite 本身已做了大量优化,核心优化方向是"减少不必要的编译":

  • 依赖预构建:Vite 自动预构建第三方依赖,生成 ESM 格式产物,可通过 optimizeDeps 自定义预构建范围。

  • 静态资源优化:内置图片、CSS 压缩,小图片自动转 base64,可通过 assetsInclude 配置。

  • 生产环境优化build 时默认开启 minify: 'terser',配置 rollupOptions 实现代码分割。

    // vite.config.js(Vue3 项目)
    import { defineConfig } from 'vite';
    import vue from '@vitejs/plugin-vue';
    
    export default defineConfig({
      plugins: [vue()],
      build: {
        minify: 'terser',
        rollupOptions: {
          output: {
            chunkFileNames: 'static/js/[name].[hash].js',
            entryFileNames: 'static/js/[name].[hash].js',
            assetFileNames: 'static/[ext]/[name].[hash].[ext]',
            manualChunks: {
              vendor: ['vue', 'vue-router', 'axios']
            }
          }
        }
      }
    });
    
    // vite.config.js(React 项目)
    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    
    export default defineConfig({
      plugins: [react()],
      build: {
        minify: 'terser',
        rollupOptions: {
          output: {
            chunkFileNames: 'static/js/[name].[hash].js',
            entryFileNames: 'static/js/[name].[hash].js',
            assetFileNames: 'static/[ext]/[name].[hash].[ext]',
            manualChunks: {
              vendor: ['react', 'react-dom', 'react-router-dom']
            }
          }
        }
      }
    });
    
  • 框架适配:Vue2 需安装 @vitejs/plugin-vue2;React 需安装 @vitejs/plugin-react(支持 Fast Refresh);Vue3 原生支持。


二、首屏加载优化(核心:减少加载时间,提升用户感知)

首屏加载速度直接影响用户留存,核心思路是"减少首屏资源体积、减少 HTTP 请求、优化资源加载顺序"。

2.1 资源层面优化

  • HTML 优化

    • 精简 HTML 结构,将首屏关键 CSS 内联到 <head>,JS 脚本放在 <body> 底部或使用 defer/async 属性。
    • React 项目:使用 react-dom/server 或 Next.js 实现服务端渲染,减少白屏时间。
    • Vue 项目:使用 vue-server-renderer(Vue2)、@vue/server-renderer(Vue3)或 Nuxt.js 实现 SSR。
  • CSS 优化

    • 提取首屏关键 CSS(Critical CSS)内联到 HTML,非关键 CSS 异步加载。
    • 使用 CSS Sprites 合并小图标,避免使用 @import 引入 CSS(会阻塞渲染)。
    • React 项目:使用 CSS Modules 或 Styled Components 避免样式冲突。
    • Vue 项目:使用 scoped 样式或 CSS Modules 减少样式冗余。
  • JS 优化

    • 减少首屏 JS 体积,非必要脚本(统计、广告)异步加载。
    • React 项目:使用 React.lazy + Suspense 拆分首屏组件,减少 useEffect 的不必要执行。
    • Vue2 项目:使用路由懒加载,用 v-if 替代 v-show(首屏不显示的组件不渲染)。
    • Vue3 项目:使用 setup 语法提升响应式效率,配合 Teleport 将非首屏组件挂载到主渲染树外。

2.2 框架专属首屏优化

2.2.1 React 项目

  • SSR/SSG:使用 Next.js,通过 getStaticProps(SSG)或 getServerSideProps(SSR)提前获取数据,首屏由服务端返回完整 HTML。
  • 预加载:使用 Next.js Link 组件的 prefetch 属性预加载路由;使用 dynamic import 动态加载非首屏组件。
  • 状态管理:首屏无需的状态延迟初始化,使用 useMemouseCallback 缓存计算结果和函数。

2.2.2 Vue2 项目

  • SSR:使用 Nuxt.js@2,通过 asyncDatafetch 提前获取首屏数据。
  • Vue 实例优化:避免在 createdmounted 中执行耗时操作,可延迟到 $nextTicksetTimeout
  • 组件优化:首屏组件精简,非首屏组件使用路由懒加载;避免使用 Vue.filter(全局过滤器会增加初始化时间)。

2.2.3 Vue3 项目

  • SSR/SSG:使用 Nuxt.js@3 或 VitePress,通过 useAsyncDatauseFetch 提前获取数据。
  • Composition API 优化setup 语法减少组件初始化时间;避免在 setup 中执行耗时操作,使用 onMounted 延迟执行。
  • 按需引入:Vue3 核心库可按需引入;Pinia 替代 Vuex(体积更小、性能更优)。

三、懒加载优化(通用+框架适配,减少首屏压力)

懒加载核心是"按需加载",仅当资源进入视口或即将进入视口时才加载。

3.1 图片懒加载

  • 原生懒加载:使用 <img loading="lazy"> 属性,浏览器原生支持,无需额外插件。不支持 IE,可做降级处理。

  • 插件懒加载(适配框架)

    • React 项目:使用 react-lazyload 或自定义 Hook(IntersectionObserver API)。

      // React 自定义懒加载 Hook
      import { useEffect, useRef, useState } from 'react';
      
      function useLazyLoad() {
        const ref = useRef(null);
        const [isVisible, setIsVisible] = useState(false);
      
        useEffect(() => {
          const observer = new IntersectionObserver(
            ([entry]) => setIsVisible(entry.isIntersecting),
            { threshold: 0.1 }
          );
          if (ref.current) observer.observe(ref.current);
          return () => {
            if (ref.current) observer.unobserve(ref.current);
          };
        }, []);
      
        return { ref, isVisible };
      }
      
      // 使用示例
      function LazyImage({ src, alt }) {
        const { ref, isVisible } = useLazyLoad();
        return (
          <div ref={ref}>
            {isVisible ? (
              <img src={src} alt={alt} />
            ) : (
              <div className="placeholder" />
            )}
          </div>
        );
      }
      
    • Vue2 项目:使用 vue-lazyload 插件。

      // Vue2 配置 vue-lazyload
      import Vue from 'vue';
      import VueLazyload from 'vue-lazyload';
      
      Vue.use(VueLazyload, {
        preLoad: 1.3,
        error: 'error.png',
        loading: 'loading.gif',
        attempt: 1
      });
      
      <!-- 组件中使用 -->
      <img v-lazy="imageUrl" />
      
    • Vue3 项目:使用 vue3-lazyload 插件。

      // Vue3 配置 vue3-lazyload
      import { createApp } from 'vue';
      import App from './App.vue';
      import VueLazyload from 'vue3-lazyload';
      
      const app = createApp(App);
      app.use(VueLazyload, {
        preLoad: 1.3,
        error: 'error.png',
        loading: 'loading.gif'
      });
      app.mount('#app');
      
      <!-- 组件中使用 -->
      <img v-lazy="imageUrl" />
      
  • 注意事项:懒加载图片需设置宽高避免布局抖动;优先使用 WebP 格式(体积更小)并做降级处理;首屏可见的图片不要使用懒加载。

3.2 组件/路由懒加载

3.2.1 React 组件/路由懒加载

  • 路由懒加载:使用 React.lazy + Suspense(参见 1.1.1)。

  • 组件懒加载:非首屏组件使用 dynamic import 动态加载。

    // React 组件懒加载示例
    import { Suspense, lazy, useState } from 'react';
    
    const ModalComponent = lazy(() => import('./components/ModalComponent'));
    
    function Home() {
      const [showModal, setShowModal] = useState(false);
      return (
        <div>
          <button onClick={() => setShowModal(true)}>打开弹窗</button>
          {showModal && (
            <Suspense fallback={<div>加载中...</div>}>
              <ModalComponent onClose={() => setShowModal(false)} />
            </Suspense>
          )}
        </div>
      );
    }
    

3.2.2 Vue2/Vue3 组件/路由懒加载

  • 路由懒加载:使用 component: () => import('xxx')(参见 1.1.1)。

  • 组件懒加载:

    // Vue2 组件懒加载
    export default {
      components: {
        LazyComponent: () => import(
          /* webpackChunkName: "lazy-component" */
          './LazyComponent.vue'
        )
      }
    };
    
    // Vue3 组件懒加载(setup 语法)
    import { defineAsyncComponent } from 'vue';
    
    const LazyComponent = defineAsyncComponent(
      () => import('./LazyComponent.vue')
    );
    
    export default {
      components: { LazyComponent }
    };
    

3.3 第三方资源懒加载

  • 第三方脚本(统计、广告、地图)异步加载,避免阻塞首屏渲染。

    // 动态加载第三方脚本
    function loadScript(url, callback) {
      var script = document.createElement('script');
      script.src = url;
      script.async = true;
      script.onload = callback;
      document.body.appendChild(script);
    }
    
    // 页面加载完成后再加载统计脚本
    window.addEventListener('load', function () {
      loadScript('https://analytics.example.com/sdk.js', function () {
        console.log('统计脚本加载完成');
      });
    });
    
  • React/Vue 项目:第三方组件(如 echarts)使用懒加载引入,避免首屏加载冗余资源。


四、Next.js 优化(React 框架专属)

Next.js 内置了大量优化特性,在此基础上补充实战优化方案。

4.1 渲染模式优化

  • SSG(静态站点生成):适用于静态页面(官网、文档),构建时生成 HTML,可部署到 CDN,首屏最快。

  • SSR(服务端渲染):适用于动态页面(用户中心、数据看板),每次请求由服务端渲染,SEO 友好。

  • ISR(增量静态再生):结合 SSG 和 SSR,构建时生成静态页面,定期重新生成。

    // Next.js ISR 示例
    export async function getStaticProps() {
      const res = await fetch('https://api.example.com/news');
      const data = await res.json();
      return {
        props: { data },
        revalidate: 60  // 每 60 秒重新生成页面
      };
    }
    

4.2 路由优化

  • 路由预加载Link 组件默认预加载视口内的路由(prefetch: true),可手动预加载。

    import Link from 'next/link';
    import { useRouter } from 'next/router';
    
    function Home() {
      const router = useRouter();
    
      const preloadAbout = () => {
        router.prefetch('/about');
      };
    
      return (
        <div>
          <Link href="/about" prefetch={true}>关于我们</Link>
          <button onClick={preloadAbout}>预加载关于我们</button>
        </div>
      );
    }
    
  • 动态路由优化:使用 getStaticPaths 配置预渲染路径;大量动态路径可设置 fallback: true,未预渲染的路径由服务端实时渲染。

4.3 资源优化

  • 图片优化:使用 Next.js 内置 Image 组件,自动压缩、格式转换、懒加载。

    import Image from 'next/image';
    
    function Home() {
      return (
        <Image
          src="/images/hero.jpg"
          alt="首页封面"
          width={1200}
          height={600}
          loading="lazy"
          quality={80}
        />
      );
    }
    
  • 字体优化:使用 Next.js 内置 Font 组件,预加载字体,避免 FOIT(字体闪烁)。

  • 脚本优化:使用 Script 组件,支持 beforeInteractiveafterInteractivelazyOnload 等加载策略。

4.4 运行时优化

  • 数据缓存:使用 SWR 或 React Query 缓存数据,减少重复请求。
  • 组件优化:使用 React.memouseMemouseCallback 避免不必要的重渲染。
  • 打包优化:通过 next.config.js 配置 optimization,开启代码分割和依赖优化。

五、运行时优化(React/Vue2/Vue3 通用)

运行时优化核心是"减少重渲染、提升交互响应速度"。

5.1 React 运行时优化

  • 减少重渲染

    import { memo, useMemo, useCallback, useState } from 'react';
    
    // 子组件:使用 memo 包裹,避免无意义重渲染
    const Child = memo(({ name, onClick }) => {
      console.log('子组件渲染');
      return <button onClick={onClick}>{name}</button>;
    });
    
    // 父组件
    function Parent() {
      const [count, setCount] = useState(0);
      const name = useMemo(() => `用户${count}`, [count]);
      const handleClick = useCallback(() => {
        console.log('点击事件');
      }, []);
    
      return (
        <div>
          <button onClick={() => setCount(count + 1)}>计数:{count}</button>
          <Child name={name} onClick={handleClick} />
        </div>
      );
    }
    
  • 事件优化:避免在 render 中创建内联函数,使用 useCallback 缓存事件处理函数;大量列表使用事件委托。

  • 数据处理优化:大量数据使用虚拟列表(react-windowreact-virtualized);耗时计算使用 Web Worker。

5.2 Vue2 运行时优化

  • 减少重渲染

    • 使用 v-once 只渲染一次不再更新的内容。
    • 使用 v-if 替代 v-show(不常显示的组件不创建 DOM)。
    • 减少 watch 监听范围,使用 computed 缓存计算属性。
  • 组件优化:拆分大型组件;使用 keep-alive 缓存路由组件。

    <!-- Vue2 keep-alive 示例 -->
    <keep-alive>
      <router-view v-if="$route.meta.keepAlive" />
    </keep-alive>
    <router-view v-if="!$route.meta.keepAlive" />
    
    // 路由配置中设置 keepAlive
    const routes = [
      {
        path: '/home',
        component: Home,
        meta: { keepAlive: true }
      },
      {
        path: '/about',
        component: About,
        meta: { keepAlive: false }
      }
    ];
    
  • 数据优化:大量数据使用虚拟列表(vue-virtual-scroller);避免在 created/mounted 中执行耗时操作。

5.3 Vue3 运行时优化

  • 减少重渲染

    • 使用 refreactive 替代 Vue2 的 data,响应式效率更高。
    • 使用 computed 缓存计算属性。
    • 使用 watchEffect 替代 watch,自动追踪依赖。
    • 使用 definePropsdefineEmits 明确组件接口。
  • 组件优化keep-alive 缓存组件;Teleport 将弹窗等组件挂载到指定节点;拆分大型组件。

  • 数据优化:虚拟列表使用 vue-virtual-scroller@next;使用 toReftoRefs 避免解构导致响应式丢失;耗时计算使用 Web Worker。


六、网络 & 缓存优化(通用)

6.1 网络优化

  • HTTP 协议:使用 HTTPS;升级到 HTTP/2(多路复用、头部压缩)。
  • CDN 加速:静态资源部署到 CDN,用户就近获取。
  • 接口优化
    • 合并接口请求,避免重复请求。
    • 分页加载,避免一次性获取大量数据。
    • 使用接口缓存(localStorage/sessionStorage)缓存不常变化的数据。
    • React 可使用 SWR/React Query,Vue 可使用 vue-query。

6.2 缓存优化

  • 浏览器缓存

    • 强缓存:Cache-Control: public, max-age=86400,浏览器直接使用本地缓存。
    • 协商缓存:ETag/Last-Modified,强缓存过期后服务器判断资源是否更新,未更新返回 304。
  • 前端缓存

    • localStorage:持久化存储不常变化的数据。
    • sessionStorage:会话级临时数据。
    • Service Worker:缓存静态资源,实现离线访问(PWA),可通过 workbox 快速配置。
  • 缓存更新策略:静态资源使用哈希命名(app.[hash].js),资源更新时哈希变化触发重新请求;HTML 文件不缓存或短时间缓存,确保能获取最新资源引用。


七、总结

前端优化是系统性工作,需结合项目场景(React/Vue2/Vue3/Next.js)和业务需求,从工程化构建、首屏加载、懒加载、运行时、网络缓存等多个层面入手。

优先级建议:构建优化(tree-shaking、代码分割)> 路由懒加载 > 图片优化 > 首屏 SSR/SSG > 运行时优化 > 网络缓存优化。

验证工具:Lighthouse、Chrome DevTools Performance 面板、webpack-bundle-analyzer,持续检测优化效果。

vue3+lodash+ts+tailwin 实现多行文本的展开收起代码(支持渲染html)

07a7ae24fef487dbb588a967f3803000.jpg

546cc5d2086a8d3c425bd07710220ae9.jpg

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { debounce } from 'lodash-es'

interface Props {
  text: string
  maxLines?: number
  expandText?: string
  collapseText?: string
  expandClass?: string
  collapseClass?: string
}

const props = withDefaults(defineProps<Props>(), {
  maxLines: 3,
  expandText: '展开',
  collapseText: '收起',
  expandClass: 'text-blue-500',
  collapseClass: 'text-blue-500',
})

const containerRef = ref<HTMLElement>()
const expanded = ref(false)
const isTruncated = ref(false)
const truncatedHtml = ref(props.text)

// ─── HTML 工具 ───────────────────────────────────────────────

/** 块级标签集合:仅这些标签会被认为产生新行,用于"在最后一行末尾追加"判断 */
const BLOCK_TAGS = new Set([
  'DIV', 'P', 'SECTION', 'ARTICLE', 'BLOCKQUOTE',
  'LI', 'UL', 'OL', 'PRE',
  'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
])

/**
 * 把 suffixHtml 注入到 html 的「最深一个块级容器」内部末尾,
 * 保证它与最后一行可见文字处于同一内联流。
 * 碰到 <strong> 等行内元素会停住,避免继承粗体等样式。
 */
function appendInsideLastBlock(html: string, suffixHtml: string): string {
  const wrapper = document.createElement('div')
  wrapper.innerHTML = html
  let target: Element = wrapper
  while (target.lastElementChild && BLOCK_TAGS.has(target.lastElementChild.tagName)) {
    target = target.lastElementChild
  }
  target.insertAdjacentHTML('beforeend', suffixHtml)
  return wrapper.innerHTML
}

/** 把 HTML 字符串转成纯文本(保留换行语义) */
function htmlToPlainText(html: string): string {
  const div = document.createElement('div')
  div.innerHTML = html
  // <br> / <p> / <div> 换成换行,方便行高量测一致
  div.querySelectorAll('br').forEach(br => br.replaceWith('\n'))
  div.querySelectorAll('p, div').forEach(el => {
    el.prepend('\n')
  })
  return div.innerText ?? div.textContent ?? ''
}

/**
 * 将"纯文本截断到第 visibleLen 个字符"映射回原始 HTML,
 * 返回一段合法闭合的 HTML 片段。
 *
 * 思路:遍历原始 HTML 字符,跳过标签字符,只计可见字符数;
 * 找到第 visibleLen 个可见字符在原始字符串中的位置后截断,
 * 再用 DOMParser 补全未闭合标签。
 */
function sliceHtmlByVisibleLen(html: string, visibleLen: number): string {
  let visible = 0
  let i = 0
  let inTag = false

  while (i < html.length && visible < visibleLen) {
    const ch = html[i]
    if (ch === '<') {
      inTag = true
    } else if (ch === '>') {
      inTag = false
    } else if (!inTag) {
      visible++
    }
    i++
  }

  // i 现在指向截断位置(继续把当前标签走完,避免截断在标签内部)
  if (inTag) {
    const closeIdx = html.indexOf('>', i)
    i = closeIdx === -1 ? html.length : closeIdx + 1
  }

  const raw = html.slice(0, i)

  // 用 DOMParser 补全未闭合标签
  const doc = new DOMParser().parseFromString(raw, 'text/html')
  return doc.body.innerHTML
}

// ─── 样式量测 ────────────────────────────────────────────────

function getLineHeight(el: HTMLElement): number {
  const lh = parseFloat(getComputedStyle(el).lineHeight)
  return isNaN(lh) ? parseFloat(getComputedStyle(el).fontSize) * 1.5 : lh
}

function createMeasureEl(el: HTMLElement, width: number): HTMLDivElement {
  const cs = getComputedStyle(el)
  const div = document.createElement('div')
  div.style.cssText = `
    position: absolute;
    visibility: hidden;
    pointer-events: none;
    width: ${width}px;
    font-size: ${cs.fontSize};
    font-family: ${cs.fontFamily};
    font-weight: ${cs.fontWeight};
    line-height: ${cs.lineHeight};
    letter-spacing: ${cs.letterSpacing};
    word-break: ${cs.wordBreak};
    white-space: ${cs.whiteSpace};
  `
  document.body.appendChild(div)
  return div
}

// ─── 截断计算 ────────────────────────────────────────────────

function calcTruncation() {
  const el = containerRef.value
  if (!el || expanded.value) return

  const cs = getComputedStyle(el)
  const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)
  if (width <= 0) return

  const lineHeight = getLineHeight(el)
  const maxHeight = lineHeight * props.maxLines
  const measureEl = createMeasureEl(el, width)

  // 用 innerHTML 量高,与实际渲染一致
  measureEl.innerHTML = props.text
  const fullHeight = measureEl.scrollHeight

  if (fullHeight <= maxHeight+1) {
    document.body.removeChild(measureEl)
    isTruncated.value = false
    truncatedHtml.value = props.text
    return
  }

  isTruncated.value = true

  // 二分搜索操作纯文本字符数
  const plain = htmlToPlainText(props.text)
  const suffix = `...${props.expandText}x` // 占位 x 抵消 ml-0.5 偏差

  let lo = 0
  let hi = plain.length

  while (lo < hi) {
    const mid = Math.floor((lo + hi + 1) / 2)
    const slicedHtml = sliceHtmlByVisibleLen(props.text, mid)
    // 把 suffix 注入到最后一个块级容器内部,量测才会跟实际渲染一致
    measureEl.innerHTML = appendInsideLastBlock(slicedHtml, suffix)
    if (measureEl.scrollHeight <= maxHeight+1) {
      lo = mid
    } else {
      hi = mid - 1
    }
  }

  document.body.removeChild(measureEl)
  truncatedHtml.value = sliceHtmlByVisibleLen(props.text, lo)
}

// ─── 生命周期 & 侦听 ─────────────────────────────────────────

const debouncedCalc = debounce(calcTruncation, 100)
let resizeObserver: ResizeObserver | null = null

onMounted(() => {
  nextTick(() => {
    calcTruncation()
    if (containerRef.value) {
      resizeObserver = new ResizeObserver(debouncedCalc)
      resizeObserver.observe(containerRef.value)
    }
  })
})

onUnmounted(() => {
  resizeObserver?.disconnect()
  debouncedCalc.cancel()
})

watch(
  () => [props.text, props.maxLines],
  () => {
    expanded.value = false
    nextTick(calcTruncation)
  },
)

// ─── 展开 / 收起 ─────────────────────────────────────────────

function expand() {
  expanded.value = true
}

function collapse() {
  expanded.value = false
  nextTick(calcTruncation)
}

// ─── 最终渲染 HTML ───────────────────────────────────────────

/**
 * 把按钮 HTML 注入到内容末尾。
 * 展开态:全文 + 收起按钮
 * 收起态:截断 HTML + ...展开按钮
 */
const btnClass = computed(() =>
  `inline ml-0.5 cursor-pointer bg-transparent border-none p-0 [font-family:inherit] [font-size:inherit] [line-height:inherit]`,
)

const renderedHtml = computed(() => {
  if (expanded.value) {
    const collapseBtn =
      `<button class="${btnClass.value} ${props.collapseClass}"
               onclick="this.dispatchEvent(new CustomEvent('collapse', { bubbles: true }))"
       >${props.collapseText}</button>`
    return appendInsideLastBlock(props.text, collapseBtn)
  }
  if (isTruncated.value) {
    const expandBtn =
      `...<button class="${btnClass.value} ${props.expandClass}"
                  onclick="this.dispatchEvent(new CustomEvent('expand', { bubbles: true }))"
       >${props.expandText}</button>`
    return appendInsideLastBlock(truncatedHtml.value, expandBtn)
  }
  return props.text
})
</script>

<template>
  <div
    ref="containerRef"
    v-html="renderedHtml"
    @expand="expand"
    @collapse="collapse"
  />
</template>

Vue 的 :deep/:global/:slotted 怎么转成 React ?一份对照指南?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 作用域样式中的穿透选择器(:deep/:global/:slotted)经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉样式 :deep/:global/:slotted 的用法。

编译对照

:global():声明全局样式

:global() 用于在 scoped 样式中声明一段不受作用域限制的全局样式。VuReact 的处理方式:移除 :global() 包装,保留内部选择器原样输出

  • Vue 代码:
<!-- Component.vue -->
<template>
  <div class="component">
    <div class="global-class">全局类</div>
  </div>
</template>

<style scoped>
.component {
  :global(.global-class) {
    color: green;
  }
}
</style>
  • VuReact 编译后 CSS:
/* component-abc123.css */
.component[data-css-abc123] {
  .global-class {
    color: green;
  }
}

从示例可以看到::global(...) 被完全移除,内部的选择器照常展开,且不添加 scope 属性。这样 .global-class 就是一个全局可用的样式类。


:deep():样式穿透

:deep() 是 scoped 样式中最常用的穿透选择器,用于让父组件的样式能够影响子组件内部的元素。VuReact 的处理策略是::deep(...) 左侧的选择器加上 scope,右侧(:deep 内部)的选择器保持原样

在嵌套规则中使用 :deep()

  • Vue 代码:
<!-- Component.vue -->
<template>
  <div class="component">
    <div class="nested-component">深层嵌套组件</div>
  </div>
</template>

<style scoped>
.component {
  :deep(.nested-component) {
    background: yellow;
  }
}
</style>
  • VuReact 编译后 CSS:
/* component-abc123.css */
.component[data-css-abc123] {
  & .nested-component {
    background: yellow;
  }
}

从示例可以看到:在嵌套规则中,:deep() 左侧是 .component(加 scope),右侧 .nested-component(不加 scope)。

在单行规则中使用 :deep()

:deep() 也可以在非嵌套的单行规则中使用,左侧部分仍然被 scoped。

  • Vue 代码:
<style scoped>
.parent :deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] .btn { color: red; }

:deep() 紧贴选择器

  • Vue 代码:
<style scoped>
.parent:deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] .btn { color: red; }

带组合器的 :deep()

  • Vue 代码:
<style scoped>
.parent > :deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
.parent[data-css-abc123] > .btn { color: red; }

:deep() 作为选择器起始

:deep() 位于选择器最左侧时(无左侧部分),VuReact 会直接用 [scopeId] 作为左侧。

  • Vue 代码:
<style scoped>
:deep(.btn) { color: red; }
</style>
  • VuReact 编译后 CSS:
[data-css-abc123] .btn { color: red; }

处理逻辑:左侧为空时,用 [data-css-abc123] 自身作为 scoped 占位。

:deep() 展开逗号选择器

:deep() 内部可以包含多个逗号分隔的选择器,VuReact 会逐一展开。

  • Vue 代码:
<style scoped>
.a :deep(.x, .y) { color: red; }
</style>
  • VuReact 编译后 CSS:
.a[data-css-abc123] .x, .a[data-css-abc123] .y { color: red; }

从示例可以看到::deep(.x, .y) 被展开为两个独立的选择器 .x.y,各自与左侧 .a[data-css-abc123] 拼接。


4. :slotted():插槽样式

:slotted() 用于为插槽传入的内容设置样式,VuReact 当前的处理方式是简单解包

  • Vue 代码:
<style scoped>
.component {
  :slotted(.slotted-content) {
    display: flex;
  }
}
</style>
  • VuReact 编译后 CSS:
.component[data-css-abc123] {
  .slotted-content {
    display: flex;
  }
}

从示例可以看到::slotted(...) 被移除,内部选择器 .slotted-content 保留,但不加 scope。完整的 :slotted() 语义支持仍在解决中。


复杂选择器共存

在一个组件中,:global:deep:slotted 可以与标准 scoped 选择器以及伪类(:hover::before 等)混合使用。

  • Vue 代码:
<style scoped>
.component {
  &:hover { opacity: 0.8; }
  &.active { font-weight: bold; }
  :global(.global-class) { color: green; }
  :deep(.nested-component) { background: yellow; }
  :slotted(.slotted-content) { display: flex; }
  &:not(:first-child) { margin-top: 20px; }
  &:nth-child(2n) { background: #f0f0f0; }
  &::before { content: '→'; }
  &::placeholder { color: gray; }
}
</style>
  • VuReact 编译后 CSS:
.component[data-css-abc123] {
  &:hover { opacity: 0.8; }
  &.active { font-weight: bold; }
  .global-class { color: green; }
  & .nested-component { background: yellow; }
  .slotted-content { display: flex; }
  &:not(:first-child) { margin-top: 20px; }
  &:nth-child(2n) { background: #f0f0f0; }
  &::before { content: '→'; }
  &::placeholder { color: gray; }
}

共处规则

选择器类型 行为 scope 注入
标准选择器 尾部追加 [data-css-xxx]
伪类/属性选择器 保持原样,插入 scope 在其之前
:global(...) 移除包装,内部不加 scope
:deep(...) 左侧加 scope,内部不加
:slotted(...) 移除包装,内部不加 scope ⚠️(待完善)

编译策略总结

VuReact 的作用域样式穿透选择器编译策略展示了完整的 scoped 选择器转换能力

  1. :global() 转换:移除 :global(...) 包装,内部选择器按全局样式输出,不加 scope
  2. :deep() 转换:将选择器按 :deep(...) 位置切割,左侧加 scope,内部保持穿透能力,支持嵌套、组合器、逗号展开等复杂场景
  3. :slotted() 转换:移除 :slotted(...) 包装,内部选择器保持原样(完整语义实现 WIP)
  4. 伪类兼容:hover::before:not():nth-child() 等伪类保持原样,scope 只插入在伪类之前
  5. 嵌套兼容:与 SCSS/Less 的 & 嵌套语法协作良好

支持的穿透选择器

选择器 状态 说明
:deep() ✅ 完整支持 左侧 scoped + 右侧穿透
:global() ✅ 完整支持 移除包装,全局样式
:slotted() ⚠️ 部分支持 解包处理,完整语义待完善

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移。编译后的 CSS 选择器既保持了 Vue scoped 样式的作用域隔离语义,又能通过 :deep():global() 灵活控制样式穿透范围,让迁移后的应用保持完整的 scoped 样式能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

9.响应式系统演进:effectScope 的作用与实现原理(Vue3.2)

前言

effectScope 是 Vue3.2 引入的一个强大响应式副作用管理工具,用于自动收集在同一个作用域内的响应式副作用(effect),以便在需要的时候可以一起销毁这些响应式副作用(effect),防止内存泄漏和意外行为。effectScope 简化了复杂代码中的响应式副作用的管理,提高了代码的可维护性,同时,effectScope 还支持嵌套作用域和独立的子作用域,即隔离副作用,总的来说它主要作用为开发者提供了灵活的响应式副作用管理方式。

effectScope 是一个底层的高级进阶 API,对于普通应用开发者一般使用不到它,但如果我们想进阶,那么就必须了解它的实现原理。如果我们想实现一些基于 Vue3 响应式库 @vue/reactivity 的公共 Hooks 库,我们就有可能需要使用到 effectScope API, 比如 vueuse 就使用到了 effectScope API。同时如果我们想要了解 Vue3.2 以后的源码也必须要了解 effectScope 的实现原理,另外还有 Vue3 状态管理库 Pinia 的源码也使用到了 effectScope API。所以说我们还是非常有必要了解它的。

在 Vue RFC 也有对其详细的解释,也可以了解一下。

注意:本篇文章实现的代码例子是在第五篇的基础上的,所以你还没看第五篇,可以先学习第五篇的内容。

在 Vue3 中什么时候需要清除响应式副作用

现在我们要实现以下这样的一个计数功能:

image.png

我们具体要实现的功能就是按 + 按钮就累计加 1,点击 清除计算结果 按钮则清除计算结果,且我们希望再次点击 + 按钮的时候也不再进行计算。

HTML 部分的代码如下:

<div>计算结果:<span id="counter"></span></div>
<button id="add">+</button>
<button id="delete">清除计算结果</button>

功能实现部分代码如下:

// 获取真实 DOM
const counterEl = document.getElementById('counter')
const addEl = document.getElementById('add')
const delEl = document.getElementById('delete')

// 利用响应式创建数据
const count = ref(0)
// 利用响应式动态变更 DOM 内容
effect(() => {
    counterEl.textContent = count.value
})
// 添加
addEl.addEventListener('click', () => {
    count.value++
})
// 清除计算结果
delEl.addEventListener('click', () => {
    const parent = counterEl.parentNode
    parent.removeChild(counterEl)
})

值得注意的是我们清除计算结果是直接删除相关 DOM 内容的。

实现结果如下:

tutieshi_494x218_7s.gif

我们从上面的实现效果来看,似乎没什么问题。

我们在动态更新 DOM 内容的 effect 执行的副作用函数中添加一个打印日志来观察一下实现效果:

effect(() => {
    counterEl.textContent = count.value
+    console.log('动态变更 DOM 内容', count.value)
})

观察结果如下:

tutieshi_504x222_5s.gif

这时我们发现,即便我们已经删除了显示计算结果的 DOM,但重新点击 + 按钮的时候,effect 的副作用函数还是继续执行。如果我们有大量这样的功能的话,那么会对我们的内存性能带来影响,所以我们需要及时释放不需要的内存,在上述例子中就是当显示计算结果的 DOM 被删除后,那么对应的响应式副作用也需要被删除,在上述例子中就是 effect 中副作用函数需要被删除。如果从发布订阅模式的角度来看,就是对应的订阅者要被删除。

删除 effect 中的副作用函数这个功能我们已经在第五篇中已经实现了,现在我们实现起来就很简单了,代码如下:

- effect(() => {
-    counterEl.textContent = count.value
-    console.log('动态变更 DOM 内容', count.value)
- })
+ const runner = effect(() => {
+    counterEl.textContent = count.value
+    console.log('动态变更 DOM 内容', count.value)
+ })
delEl.addEventListener('click', () => {
+    runner.effect.stop()
    const parent = counterEl.parentNode
    parent.removeChild(counterEl)
})

我们再来看看修改后的执行效果:

tutieshi_510x166_4s.gif

这时我们发现在删除相关 DOM 的时候同时清除相关的副作用函数,即便对应的响应式数据发生变化,那些已经被删除的副作用函数就不再执行了,这样就达到优化内存,提高响应式框架程序性能的作用了。

如果上述功能是一个 Vue3 的应用的话,计算结果可以使用一个组件来实现,那么当清除计算结果的时候,可以看作卸载计算结果的组件,那么也就是说在卸载组件的时候需要清除对应组件的响应式副作用函数

Vue3 组件的响应式副作用的收集与清除

在 Vue3.15 的版本的源码中,也就是 effectScope 相关代码提交的前一个版本,我们可以看到 Vue3 组件的响应式副作用收集过程是如下的:

image.png

首先在组件初始化的时候,会通过实例化 ReactiveEffect 类创建一个副作用对象,并且赋值给组件实例 instance.effect 上。

组件卸载的时候:

image.png

我们可以看到组件卸载的时候,又会从组件实例对象上取 ReactiveEffect 类的实例对象,然后执行 stop 方法清除组件的响应式副作用。

上述通过 ReactiveEffect 类创建的副作用对象主要应用于组件的 render 函数的包装函数,是 Vue3 系统底层自动创建的。而一个组件的响应式副作用并不止组件的 render 函数的包装函数,还有用户通过 watch、watchEffect、computed API 手动创建的响应式副作用。

例如 watch API:

image.png

在 watch API 的实现中也是通过实例化 ReactiveEffect 类创建一个副作用对象,然后再通过 recordInstanceBoundEffect 函数保存起来。recordInstanceBoundEffect 函数实现如下:

image.png

recordInstanceBoundEffect 函数实现的实现很简单,就是将用户通过 watch、watchEffect、computed API 手动创建的 ReactiveEffect 类的实例对象存储到组件实例对象的 effects 属性上。这样在组件卸载的时候,就可以通过获取组件实例上 effects 属性的值进行执行达到取消相关响应式副作用的目的。相关实现如下:

image.png

这个就是 Vue3 组件的响应式副作用是如何收集与清除的实现原理。在 Vue3 源码底层已经自动帮我们实现了在 Vue 组件的 setup 中,初始化的时候响应式副作用将被收集并绑定到当前实例,在实例被卸载的时候,响应式副作用则会自动的被取消追踪了。注意上述的实现是 Vue3.15 中的实现。在 Vue3.2 以后就通过 effectScope 进行实现了,那么为什么要通过 effectScope 进行实现呢?

手动处理响应式副作用的弊端

经过上文我们知道响应式副作用失效之后需要及时把它们销毁掉,否则会存在内存泄漏和意外行为的风险。而在 Vue3 的底层已经自动帮我们实现了响应式副作用的处理,我们在平时写应用的时候无需担心。但我们如果想实现一些基于 Vue3 响应式库 @vue/reactivity 的公共库的时候,我们可能就需要手动处理响应式副作用了。

例如下面的代码例子:

const count1 = ref(0)
const count2 = ref(0)
// 用于存储副作用对象,以便后续可以停止它们
const effectStacks = []
// 观察响应式变量 count1 的变化情况
const effect1 = effect(() => {
    console.log(`effect1:${count1.value}`)
})
// 手动收集 effect1 的副作用
effectStacks.push(effect1)
// 观察响应式变量 count2 的变化情况
const effect2 = effect(() => {
    console.log(`effect2:${count2.value}`)
})
// 手动收集 effect2 的副作用
effectStacks.push(effect2)

// 通过设置定时器每一秒让 count1 和 count2 自动增加 1
const t = setInterval(() => {
    if (count1.value === 2) {
        // 如果等于 2,则遍历 effectStacks 数组,调用每个副作用对象的 stop 方法来停止它们。
        effectStacks.forEach(effect => effect.effect.stop())
        clearInterval(t)
    } else {
        count1.value++
        count2.value++
    }
}, 1000)

我们上述代码使用 ref 创建了两个响应式变量 count1 和 count2,初始值都为 0,然后通过 effect 函数定义了两个响应式副作用 effect1 和 effect2 用来分别观察响应式变量 count1 和 count2 的变化情况,并且将这两个响应式副作用对象手动收集到 effectStacks 数组中。然后使用 setInterval 设置了一个定时器,每隔 1 秒执行一次,在定时器的回调函数中检查 count1 的值是否等于 2,如果等于 2,则遍历 effectStacks 数组,调用每个副作用对象的 stop 方法来停止它们,否则递增 count1 和 count2 的值。

总的来说就是通过手动收集副作用对象,可以在特定条件下(如 count1 达到 2)停止这些副作用,从而控制程序的执行流程。

现在我们再增加两个响应式变量 count3 和 count4,再分别观察它们的变化情况。

// 省略...
+ const count3 = ref(0)
+ const count4 = ref(0)
// 省略...

+ // 观察响应式变量 count3 的变化情况
+ const effect3 = effect(() => {
+    console.log(`effect1:${count3.value}`)
+ })
+ // 手动收集 effect3 的副作用
+ effectStacks.push(effect3)
+ // 观察响应式变量 count4 的变化情况
+ const effect4 = effect(() => {
+     console.log(`effect2:${count4.value}`)
+ })
+ // 手动收集 effect4 的副作用
+ effectStacks.push(effect4)

// 通过设置定时器每一秒让 count1 和 count2 自动增加 1
const t = setInterval(() => {
    if (count1.value === 2) {
        // 如果等于 2,则遍历 effectStacks 数组,调用每个副作用对象的 stop 方法来停止它们。
        effectStacks.forEach(effect => effect.effect.stop())
        clearInterval(t)
    } else {
        count1.value++
        count2.value++
+        count3.value++
+        count4.value++
    }
}, 1000)

现在我们想实现当 count1 的值等于 2 的时候停止对 count3count4 的观察,也就是要停止 effect3effect4 的副作用。这时我们发现要实现这个比较麻烦,需要我们重新定义一个全局存储 effect3effect4 的副作用的变量。

+ const effectStacks2 = []

// 观察响应式变量 count3 的变化情况
const effect3 = effect(() => {
    console.log(`effect1:${count3.value}`)
})
// 手动收集 effect3 的副作用
- effectStacks.push(effect3)
+ effectStacks2.push(effect3)
// 观察响应式变量 count4 的变化情况
const effect4 = effect(() => {
    console.log(`effect2:${count4.value}`)
})
// 手动收集 effect4 的副作用
- effectStacks.push(effect4)
+ effectStacks2.push(effect4)

// 通过设置定时器每一秒让 count1 和 count2 自动增加 1
const t = setInterval(() => {
    if (count1.value === 2) {
        // 如果等于 2,则遍历 effectStacks2 数组,调用每个副作用对象的 stop 方法来停止对 `count3` 和 `count4` 的观察。
-        effectStacks.forEach(effect => effect.effect.stop())
+        effectStacks2.forEach(effect => effect.effect.stop())
        clearInterval(t)
    } else {
        count1.value++
        count2.value++
        count3.value++
        count4.value++
    }
}, 1000)

我们发现目前我们对响应式副作用的管理是非常麻烦的,怎么可以实现非常方便地管理响应式副作用呢?这时我们的 effectScope 就要登场了。

effectScope 的实现原理

我们在上一小节遇到的问题就是目前我们对响应式副作用的管理是非常的麻烦,我们希望可以很方便地把响应式副作用 effect1effect2 归一组,把 effect3effect4 归一组。其实在 Vue3 组件的响应式副作用的收集与清除 那小节中可以知道,每个组件的响应式副作用都自动收集到组件实例对象上了,所以在组件卸载的时候,也就很方便把相关的副作用也卸载了。那么有什么方案呢?

其实对发布订阅模式理解透彻的同学,可以很清楚地知道,我们在上一小节中实现的手动进行处理响应式副作用的方法,本质就是一个发布订阅模式的应用。

首先是创建一个订阅者存储中心的变量:

const effectStacks = []

然后所谓手动收集每个响应式副作用对象,其实是订阅的动作。

effectStacks.push(effect1)

最后在需要的时候,去通知每一个订阅者。

effectStacks.forEach(effect => effect.effect.stop())

这其实就是发布订阅模式的最核心的要义。

通过我们前面章节对发布订阅模式的学习,我们知道订阅者存储中心可以由一个叫消息代理中心类来实现,例如我们前面实现的 EventBus,通过 new EventBus() 我们就可以创建不同分组的事件总线,很明显这个模式同样适合我们上面的需求。那么如果你熟悉发布订阅模式的话,你可以很快写出我们现在需要实现的消息代理中心类 EffectScope 的基本框架代码。

那么根据我们前面实现 EventBus 类或者消息代理类的实现,我们可以得出以下代码:

class EffectScope {
    // 响应式副作用对象存储中心
    effects = []
    constructor() {

    }
    // 订阅,也就是收集响应式副作用对象
    sub() {

    }
    // 通知,也就是停止收集到的响应式副作用对象
    notify() {
        this.effects.forEach(e => e.stop())
    }
}

现在我们就可以通过以下方式创建不同的响应式副作用分组了。代码如下:

const scope = new EffectScope()

那么接下来就需要思考怎么去实现把响应式副作用对象收集到 EffectScope 类内部的 effects 属性上。在代码实现上我们可以参考 effect 函数的实现,代码如下:

const count1 = ref(0)
const count2 = ref(0)
scope.sub(() => {
    effect(() => {
        console.log(`effect1:${count1.value}`)
    })
    effect(() => {
        console.log(`effect2:${count2.value}`)
    })
})

就是给 sub 方法传递一个包装函数,那么在 EffectScope 类中的 sub 方法最终需要执行一下这个包装函数。

class EffectScope {
    // 省略...
    sub(fn) {
       fn()
    }
   // 省略...
}

通过前面对 Vue3 响应式原理的学习,我们知道所谓响应式副作用对象其实就是 ReactiveEffect 类的实例对象。那么也就是说在实例化 ReactiveEffect 类的时候就需要去把 ReactiveEffect 类的实例对象添加到 EffectScope 类的 effects 属性上。

首先我们需要创建一个记录当前激活的作用域对象的全局变量。代码如下:

+ // 记录当前激活的作用域对象
+ let activeEffectScope
class EffectScope {
    // 省略...
    sub(fn) {
+        activeEffectScope = this
        fn()
+        activeEffectScope = null
    }
   // 省略...
}

如果还记得 Vue 响应式原理的实现的同学,应该对上述代码的套路很熟悉,所以我们真的彻底理解底层的知识,那么学习其他相关的知识就能达到触类旁通的效果,这也是为什么有些人学习新知识学得那么快的原因。

接下来我们就可以在实例化 ReactiveEffect 类的时候就需要去把 ReactiveEffect 类的实例对象添加到全局变量 activeEffectScopeeffects 属性上即可。代码实现如下:

class ReactiveEffect {
    deps = []
    constructor(fn) {
        this._fn = fn
+        // 在定义副作用时,自动将它们关联到当前的作用域。
+        if (activeEffectScope) {
+            activeEffectScope.effects.push(this)
+        }
    }
    // 省略...
} 

这样我们就可以进行重新测试了,测试代码如下:

setInterval(() => {
    console.log('=====')
    if (count1.value === 2) {
        scope1.notify()
    }
    count1.value++
    count2.value++
}, 1000)

测试结果如下:

tutieshi_454x284_6s.gif

从测试结果可以看到,我们实现了通过作用域对响应式副作用对象的收集和卸载是成功的。

为了我们的代码更有语义,我们对上述代码进行迭代优化:

class ReactiveEffect {
    deps = []
    constructor(fn) {
        this._fn = fn
-        if (activeEffectScope) {
-            activeEffectScope.effects.push(this)
-        }
+        recordEffectScope(this)
    }
    // 省略...
} 

// 省略...

+ function recordEffectScope(effect) {
+     if (activeEffectScope) {
+         activeEffectScope.effects.push(effect)
+     }
+ }

封装一个在定义副作用时,自动将它们关联到当前的作用域的函数:recordEffectScope

同时修改 EffectScope 类中的相关方法的名称让它们更具有语义性。具体修改如下:

class EffectScope {
    // 省略...
-    sub() {
+    run(fn) {
    // 省略...
    }
    
-    notify() {
+    stop() {
        // 省略...
    }
}

+ // 创建作用域的工厂函数
+ function effectScope() {
+     return new EffectScope()
+ }

同时封装了一个创建作用域的工厂函数 effectScope

这时我们再实现我们之前的需求就很方便了。代码实现如下:

const count1 = ref(0)
const count2 = ref(0)
const count3 = ref(0)
const count4 = ref(0)
// 作用域1
const scope1 = effectScope()
scope1.run(() => {
    effect(() => {
        console.log(`effect1:${count1.value}`)
    })

    effect(() => {
        console.log(`effect2:${count2.value}`)
    })
})
// 作用域2
const scope2 = effectScope()
scope2.run(() => {
    effect(() => {
        console.log(`effect3:${count4.value}`)
    })

    effect(() => {
        console.log(`effect4:${count4.value}`)
    })
})

setInterval(() => {
    console.log('=====')
    if (count1.value === 1) {
        // 当 count1 等于 1 时停止作用域2的依赖追踪
        scope2.stop()
    }
    count1.value++
    count2.value++
    count3.value++
    count4.value++
}, 1000)

测试结果如下:

tutieshi_460x444_4s.gif

自此我们就实现了 effectScope 的最核心的功能,本质上就是一个发布订阅模式的应用,effectScope 函数是一个工厂函数,通过实例化 EffectScope 类,创建不同的作用域对象,而 EffectScope 类本质上是发布订阅模式中的消息代理类或者我们经常说的事件总线类,然后通过 run 方法运行一个包装函数,本质上是在订阅响应式副作用对象,最后可以通过 stop 方法通知每个订阅的响应式副作用对象进行停止追踪响应式依赖。所以如果你对发布订阅模式非常熟悉,那么你对 effectScope 的实现原理也非常容易理解了。

嵌套作用域

我们目前想实现这样的功能,在一个作用域里面嵌套一个作用域,代码如下:

const count1 = ref(0)
const count2 = ref(0)
const count3 = ref(0)
const count4 = ref(0)
const scope1 = effectScope()
// 作用域1
scope1.run(() => {
    effect(() => {
        console.log(`effect1:${count1.value}`)
    })

    effect(() => {
        console.log(`effect2:${count2.value}`)
    })
    // 嵌套作用域
    const scope2 = effectScope()
    scope2.run(() => {
        effect(() => {
            console.log(`effect3:${count4.value}`)
        })

        effect(() => {
            console.log(`effect4:${count4.value}`)
        })
    })
})

setInterval(() => {
    console.log('=====')
    if (count1.value === 1) {
        // 停止外层作用域的依赖追踪
        scope1.stop()
    }
    count1.value++
    count2.value++
    count3.value++
    count4.value++
}, 1000)

我们想当停止外层作用域的依赖追踪后,嵌套的作用域中的依赖也停止追踪。目前测试结果如下:

tutieshi_444x392_4s.gif

我们发现当我们停止了外层作用域的依赖追踪后,嵌套的作用域中的依赖还是能够进行追踪的,这是因为我们目前是已经实现了作用域隔离,也就是不同作用域中的依赖是互不干扰的,但有些场景可能我们又需要嵌套作用域是能够关联的,也就是停止了外层作用域,嵌套的作用域也应该停止。

要实现这个功能,其实也很简单,还是通过发布订阅模式的应用去实现,从上文可以知道,effectScope 的实现原理本质就是发布订阅模式的应用,EffectScope 类就是消息代理中心,所谓订阅者就是 ReactiveEffect 类的实例对象。从在们前面所学的知识可以知道,订阅者也可以是发布者,发布者也可以是订阅者,或者说观察者也可以是被观察者,被观察者也可以是观察者。

所以根据这个规则,我们可以让父级的 EffectScope 订阅嵌套的 EffectScope。代码实现如下:

class EffectScope {
    effects = []
    constructor() {
        // 订阅嵌套的 EffectScope
+        recordEffectScope(this)
    }
    // 省略...
}

而 EffectScope 类上有个 stop 方法,而 ReactiveEffect 类上也有一个 stop 方法,所以在执行父级作用域的 stop 方法循环 effects 属性上的订阅者的时候,有可能是嵌套的作用域,而因为都共同拥有一个 stop 方法,所以在执行嵌套作用域的实例对象的 stop 方法的时候又会去循环嵌套作用域中 effets 属性中订阅者,这样就实现了父作用域与嵌套作用域的依赖的共同管理了。

这时我们再来测试一下上述的嵌套作用域的测试代码。测试结果如下:

tutieshi_444x324_4s.gif

这时我们发现清除父级作用域的时候,嵌套作用域的响应式副作用也被清除了。

我们还需要继续迭代一下我们的功能,现在是默认就关联收集了嵌套作用域了,这样就失去了隔离作用域的作用了。那么我们希望做一个开关,开关开启的时候就进行作用域隔离,默认就收集嵌套作用域的响应式副作用。

实现代码如下:

class EffectScope {
    // 省略...
-    constructor() {
+    constructor(detached = false) {
+        if (!detached) {
            recordEffectScope(this)
+        }
    }
    // 省略...
}

// 创建作用域的工厂函数
- function effectScope() {
+ function effectScope(detached) {
-    return new EffectScope()
+    return new EffectScope(detached)
}

这样我们就初步实现了 effectScope 功能了。

在 Vue3 底层应用 effectScope

在 Vue3.2 以后 Vue3 组件的响应式副作用的收集与清除的实现就通过 effectScope 进行了。通过上文我们知道一个组件的响应式副作用是有两种类型的,分别是由组件的 render 函数的包装函数和用户通过 watch、watchEffect、computed API 手动创建的响应式副作用。在 Vue3.2 以前,它们分别收集在组件实例的 effect 和 effects 两个属性上。在 Vue3.2 以后实现就通过 effectScope 进行实现了,就只需需要一个 scope 属性来存储 EffectScope 实例对象即可。

image.png

从上图我们可以看到在 Vue3.2 以后组件实例化后,也会在组件实例对象的 scope 属性实例化一个 EffectScope 实例对象。

然后我们知道一个组件的响应式变量是在 setup 方法中创建的,然后在 render 方法中使用,当响应式变量发生变化的时候,render 函数重新执行,而要实现这个功能是通过 ReactiveEffect 来实现的。

image.png

然后通过上文对 effectScope 的实现原理的讲解我们知道,在实例化 ReactiveEffect 的时候,会把 ReactiveEffect 实例对象收集到 EffectScope 的实例对象的 effects 属性上。然后在组件卸载的时候,就可以通过组件实例对象上的 scope 属性的 stop 方法进行卸载相关的副作用了。

image.png

隔离副作用的实际应用

我们使用 Vue3 Composition API 编写一个自定义钩子(hook)函数,名为 useCounter。它的功能是实现一个简单的计数器,并附带了一个额外的特性:当计数器的值是偶数时,计算并存储这个值的两倍。

以下是 useCounter 的代码实现:

import { ref, watch } from "vue"

export function useCounter() {
    // 定义计数器
    const counter = ref(0)
    // 增加
    const increment = () => counter.value++
    // 减少
    const decrement = () => counter.value--
    // 计数器的偶数双倍值
    const doubleCount = ref(0)
    // 监听计数器值的变化
    watch(() => counter.value, (newVal) => {
        // 当计数器的值是偶数时,计算并存储这个值的两倍
        if (newVal % 2 === 0) {
            doubleCount.value = newVal * 2 
        }
    })

    return {
      counter,
      doubleCount,
      increment,
      decrement
    }
}

接着我们在两个组件中使用它。

Counter1.vue

<template>
    <div>
        <p>当前值: {{state.counter}}</p>
        <p>偶数双倍值:{{state.doubleCount}}</p>
        <button @click="state.increment">+</button>
        <button @click="state.decrement">-</button>
    </div>
</template>
<script setup>
import { useCounter } from '../hooks/useCounter';
const state = useCounter()
</script>

Counter2.vue

<template>
    <div>
        <p>当前值: {{state.counter}}</p>
        <p>偶数双倍值:{{state.doubleCount}}</p>
        <button @click="state.increment">+</button>
        <button @click="state.decrement">-</button>
    </div>
</template>
<script setup>
import { useCounter } from '../hooks/useCounter';
const state = useCounter()
</script>

接着在 App.vue 中引用它们。

App.vue

<script setup>
import Counter1 from './components/Counter1.vue'
import Counter2 from './components/Counter2.vue'
</script>

<template>
  <Counter1 />
  <Counter2 />
</template>

实现效果如下:

tutieshi_442x432_12s.gif

我们当前的实现是两个组件的状态是不共享的,分别各自计算各自的值,现在我们希望它们是互相共享状态的,也就是点击 组件1 中的按钮进行计算的时候,组件2 中的状态也是同时改变的,同样地点击组件2中的按钮进行计算的时候,组件1中的状态也是同时改变的。

通常要在多个组件之间共享数据状态,我们一般在最上层的父组件创建响应式变量,然后通过层层传递进行使用,这种很明显层级过多时候很不方便;或者使用 Vuex 或者 Pinia,但一般在小型项目中,比如我们上述的计数器功能,如果我们也引用这种第三方库,代码就显得很臃肿了。所以我们可以自己实现一个小型的状态管理工具函数。

那么我们要实现在多个组件共享数据状态,本质是要创建一个单例的数据状态变量,也就是单例模式的应用。

单例模式是一种设计模式,目的是确保一个类或者对象在整个应用生命周期中只被实例化一次,并提供全局访问点。

在 JavaScript 中,单例模式通常通过闭包来实现,利用闭包保存一个私有的实例变量,同时通过一个函数来控制创建和访问这个实例。

具体代码实现如下:

function createGlobalState(stateFactory) {
    let initialized = false;
    let state;
    return ((...args) => {
      if (!initialized) {
        state = stateFactory(...args);
        initialized = true;
      }
      return state;
    });
}

上面的 JavaScript 代码通过闭包和函数表达式实现了一个简单的单例模式,确保某个状态(state)对象只会被创建一次,并始终返回同一个实例。

createGlobalState 是一个工厂函数,它接受一个参数 stateFactory,这个参数也是一个工厂函数,负责生成状态对象。也就是说,我们把状态对象的创建逻辑封装在 stateFactory 中。对于我们上面的计算器的实现例子,那么这个参数就是 useCounter 函数。使用例子如下:

export const useCounterState = createGlobalState(useCounter)

createGlobalState 返回的是一个匿名函数(箭头函数),从上述例子可以知道变量 useCounterState 就是一个函数,这个函数会被用来获取状态对象。

在 createGlobalState 函数内部,声明了两个私有变量:initialized 标记状态对象是否已经被初始化(默认值是 false), state 变量存储状态对象的引用。只有当 initialized 是 false 时,才会调用 stateFactory 创建状态对象,并将其赋值给 state。同时将 initialized 设置为 true,表示状态对象已经被创建。这样每次调用匿名函数时,都会返回同一个 state 对象,从而实现单例模式的效果。

接下来我们在两个组件 Counter1.vue 和 Counter2.vue 中进行以下引用:

import { useCounterState } from '../hooks/useCounter';
const state = useCounterState();

然后测试结果如下:

tutieshi_420x408_8s.gif

这时,我们可以看到两个组件的状态实现了互相共享,也就是点击 组件1 中的按钮进行计算的时候,组件2 中的状态也是同时改变的,同样地点击组件2中的按钮进行计算的时候,组件1中的状态也是同时改变的。

至此我们好像还没讲到实现副作用隔离的作用是什么。接下来我们再实现一个小功能,代码如下:

<script setup>
import { ref } from 'vue'
import Counter1 from './components/Counter1.vue'
import Counter2 from './components/Counter2.vue'

+ const isShow = ref(true)
+ const handleHide = () => {
+   isShow.value = false
+ }
</script>

<template>
-  <Counter1 />
+  <Counter1 v-if="isShow" />
  <Counter2 />
+  <button @click="handleHide">隐藏第一个组件</button>
</template>

实现效果如下:

tutieshi_392x384_10s.gif

我们可以看到当我们隐藏第一个组件之后,第二个组件的偶数双倍值失效了。这是为什么呢?首先是因为偶数双倍值的实现是通过 watch 来实现的,从而产生了一个副作用,并且因为第一个组件是最新执行的,所以这个副作用就被收集到了第一个组件的实例对象上,而又因为我们是通过单例模式实现了状态共享,所以第二个组件使用的状态变量实际上跟第一个组件使用的状态变量是同一个,所以第一个组件使用 watch 产生的副作用被隐藏从而删除之后,第二个组件的相关功能也就失效了。

所以这个时候,我们就要想办法,让这些第三方的库产生的副作用不要和组件进行绑定,而是要和组件进行隔离,这个时候很明显就需要用到 effectScope 功能了,也是 effectScope 功能的最大作用之一。所以我们对 createGlobalState 函数进行修改,具体修改如下:

function createGlobalState(stateFactory) {
    let initialized = false;
    let state;
+    const scope = effectScope(true)
    return ((...args) => {
      if (!initialized) {
-        state = stateFactory(...args);
+        state = scope.run(() => stateFactory(...args));
        initialized = true;
      }
      return state;
    });
}

通过上文我们知道 effectScope 函数传参为 true 时就会进行作用域隔离。

这时我们再进行测试:

tutieshi_274x374_9s.gif

这时我们发现当我们隐藏第一个组件的时候,第二个组件的偶数双倍值功能不再受影响了。

至此 Vue3 中新增的 effectScope API 功能的实现原理和相关作用我们都介绍得差不多了。

总结

effectScope 是 Vue 3.2 提供的高阶响应式副作用管理工具,其核心本质是发布订阅模式的应用。通过 EffectScope 类作为消息代理中心,run 方法负责收集当前作用域内的所有 ReactiveEffect 实例(即副作用),stop 方法则批量停止它们。它还支持嵌套作用域,通过 detached 参数控制父子作用域是否关联,实现了灵活的副作用隔离。

在 Vue 3.2 之后,组件内部使用 effectScope 统一管理渲染副作用和用户定义的 watch/computed 副作用,替代了之前分散在 instance.effect 和 effects 数组的手动管理方式,简化了代码并提升了内存安全。此外,在开发可复用的组合式函数(如 createGlobalState 实现全局状态共享)时,利用隔离的 effectScope 可以避免副作用被错误绑定到特定组件上,从而保证状态跨组件共享时的正确性。掌握 effectScope 有助于深入理解 Vue 3 响应式系统及构建更健壮的公共库。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

Vue3 + Three.js 仓储数字孪生:按需渲染架构与五大核心功能复盘

🛠️ Vue3 + Three.js 仓储数字孪生:按需渲染架构与五大核心功能复盘

在重构企业级 3D 仓储数字孪生项目的过程中,我摒弃了原项目过度封装的插件机制,转而采用 Vue 3 Composition API 结合 Three.js 原生 API 进行开发。

为了追求极致的性能,我在这次重构中彻底去掉了传统的 requestAnimationFrame 死循环,采用了针对静态场景性能最佳的**“按需渲染(On-Demand Rendering)”**架构。本文将详细复盘我实现的五大核心功能,并给出对应的核心脱水代码。

💡 功能一:搭建纯粹的 3D 舞台与光影配置

需求目标:在浏览器中初始化 3D 画布,并引入相机、环境光与平行光,为后续的模型加载提供基础环境。

实现思路: 在 Vue 的 onMounted 钩子中,构建 Three.js 的核心对象。由于我们放弃了动画循环,在场景初始化完毕后,必须手动调用一次 renderer.render() 来“按下快门”拍下第一张照片。

核心代码

import * as THREE from 'three';

// 1. 场景与光影
const scene = new THREE.Scene();
scene.background = new THREE.Color('#2b2b2b');

// 添加环境光(提亮全局)与平行光(制造立体感)
scene.add(new THREE.AmbientLight(0xffffff, 0.8));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
dirLight.position.set(10, 20, 10);
scene.add(dirLight);

// 2. 相机配置
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
camera.position.set(0, 15, 25);

// 3. WebGL 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
containerRef.value.appendChild(renderer.domElement);

// 初始化完成后,手动渲染第一帧(极其重要,否则黑屏)
renderer.render(scene, camera);

🏭 功能二:工业级 GLB 模型的解析与加载

需求目标:将大厂工业级的 .glb 仓储模型加载到场景中展示,并解决 Meshopt 压缩网格的解析报错。

实现思路: 数字孪生的高精度模型往往使用 Meshoptimizer 进行压缩,以极大地减小网络传输体积。在实例化 GLTFLoader 后,必须强行注入 MeshoptDecoder。模型异步加载完成后,因为没有动画循环自动重绘,必须在回调函数里手动触发一次渲染

核心代码

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';

const gltfLoader = new GLTFLoader();
// 核心:解决工业级模型的网格压缩报错
gltfLoader.setMeshoptDecoder(MeshoptDecoder); 

gltfLoader.load('/warehouse.glb', (gltf) => {
  scene.add(gltf.scene);
  
  // 模型加载并添加到场景后,必须手动刷新画面才能看到
  renderer.render(scene, camera);
  console.log('模型加载成功并已渲染!');
});

🎯 功能三:射线拾取、克隆高亮与信息标签

需求目标:鼠标点击 3D 屏幕,选中特定货架使其变红高亮,并在货架正上方弹出 HTML 信息标签。

实现思路: 利用 Raycaster 将鼠标二维坐标转换为 3D 射线检测碰撞。

  • 高亮去重:使用 material.clone() 剥离共享材质,防止“牵一发而动全身”。
  • 标签定位:使用 CSS2DRenderer 配合 THREE.Box3 计算货架的世界绝对最高点,规避模型复杂的内部层级带来的局部坐标偏移。
  • 按需更新:高亮和弹窗发生后,手动调用两者的 render 方法刷新画面。

核心代码

import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';

// ... 提前初始化 labelRenderer 并挂载到 DOM ...
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

const onMouseClick = (event) => {
  // 坐标转换与射线检测
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(scene.children, true);

  if (intersects.length > 0) {
    const obj = intersects[0].object;
    
    // 1. 材质克隆与独立高亮
    obj.material = obj.material.clone(); 
    obj.material.color.set('#ff0000');   

    // 2. CSS2D 标签绝对坐标计算
    const div = document.createElement('div');
    div.textContent = `📍 ${obj.name}`;
    div.className = 'three-label'; 
    const label = new CSS2DObject(div);

    const box = new THREE.Box3().setFromObject(obj);
    const center = new THREE.Vector3();
    box.getCenter(center);
    label.position.set(center.x, box.max.y + 0.5, center.z);
    scene.add(label);

    // 3. 核心:状态改变后,手动更新 WebGL 和 CSS2D 画面
    renderer.render(scene, camera);
    labelRenderer.render(scene, camera);
  }
};

🕹️ 功能四:基于 Change 事件的视角操作(缩放与旋转)

需求目标:使用鼠标拖拽旋转、滚轮缩放查看模型细节,同时摒弃耗费 GPU 的全局动画循环

实现思路: 引入 OrbitControls 接管相机的操作。重要避坑:为了实现“按需渲染”,必须关闭控制器的阻尼效果(enableDamping = false),否则没有动画循环为其计算数学递减,拖拽会严重卡顿。随后,我们将画面刷新逻辑直接绑定在控制器的 change 事件上。

核心代码

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

const controls = new OrbitControls(camera, renderer.domElement);

// ⚠️ 大厂优化铁律:采用按需渲染时,必须关闭阻尼惯性
controls.enableDamping = false; 

// 监听用户的鼠标/触摸操作
controls.addEventListener('change', () => {
  // 只有当相机视角发生真实变化时,才“按需”冲洗照片
  renderer.render(scene, camera);
  
  if (labelRenderer) {
    labelRenderer.render(scene, camera);
  }
});

💥 功能五:包围盒控制(相机空气墙)防穿模

需求目标:防止用户在缩放或平移视角时,不小心把相机钻到地板下面,或者穿出仓库大楼外部导致画面穿帮。

实现思路: 不使用庞大的物理引擎,利用原生 THREE.Box3 定义一个安全的可视空间(空气墙)。借助上一步实现的 change 按需渲染机制,在每次视角变化准备渲染之前,使用 Vector3.clamp() 方法强制将相机的坐标钳制在安全盒子内部。

核心代码

// 1. 划定一个仓库的安全边界(空气墙 Box3)
// 假设仓库范围是 X(-50~50), Y(1~30), Z(-50~50)
const safeBounds = new THREE.Box3(
  new THREE.Vector3(-50, 1, -50), // Y轴最小为1,防止钻入地板
  new THREE.Vector3(50, 30, 50)   // 限制最大高度和边界
);

controls.addEventListener('change', () => {
  // 2. 碰撞钳制逻辑:一旦相机试图越出边界,强制将其拉回安全区域边缘
  camera.position.clamp(safeBounds.min, safeBounds.max);
  
  // 3. 渲染钳制后的合法画面
  renderer.render(scene, camera);
  if (labelRenderer) labelRenderer.render(scene, camera);
});

🚀 总结与架构沉淀

通过这次重构,我不仅掌握了从模型加载、射线拾取到碰撞检测的完整 3D 链路,更深刻体会到了**“按需渲染(On-Demand Rendering)”**在前端工程中的威力。

去除了全局的 requestAnimationFrame 后,当用户不操作页面时,GPU 占用率直接降为 0%。结合 Vue 3 优秀的响应式系统,这种极致轻量、彻底解耦的代码架构,才是现代化 Web 3D 项目应该追求的形态。

Vue 响应式对象异步赋值作为 Props:二次渲染问题与组件设计哲学

前言:一个看似简单的场景

<!-- 父组件 -->
<template>
  <Article
    :title="articleTitle"
    :description="articleDescription"
  />
</template>

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

const articleTitle = ref("")
const articleDescription = ref("")

// 这里发起请求
fetchArticles(user, publishedDate).then(response => {
  articleTitle.value = response.data.title // 响应式数据发生变化,派发更新
  articleDescription.value = response.data.description
})
</script>

上面的代码会导致子组件 <Article> 渲染两次:第一次收到空字符串,第二次收到真实数据。

这看起来只是一个技术细节。但当开发者把目光从“如何解决”转向“为什么会产生这个问题”时,会发现它触及了 Vue 组件设计中一个深层的问题——数据所有权与副作用边界之间的张力


问题复现——二次渲染的本质

执行过程

  1. 组件挂载前articleTitlearticleDescription 初始值为空字符串。
  2. 首次渲染:子组件收到 { title: "", description: "" },完成第一次渲染(空白或加载占位)。
  3. 异步数据返回articleTitle.value = ... 触发 ref 的 setter。
  4. 父组件重新渲染:Vue 检测到响应式数据变化,重新执行父组件的 render 函数。
  5. 子组件二次渲染:子组件因为 props 变化而再次更新,显示正确内容。

结果:子组件渲染了两次(一次空数据,一次真实数据)。

为什么需要关注这个问题?

并非所有场景都需要关注二次渲染。但在以下情况中,它会成为实际问题:

  • 子组件内部开销较大:图表库、大量 DOM 计算等重复执行,造成性能浪费。
  • 子组件依赖 props 发起副作用:比如 watchEffect 根据 props 去请求图片或接口,导致请求重复发送。
  • 动画或过渡异常:元素从无到有,又从有到空再到有,造成视觉闪烁。
  • 表单组件收到两次初始值:可能导致用户输入被意外重置。

一个常见的“改进”及其设计困境

面对上述问题,很多开发者会做出一个看似更优的选择:

<!-- 父组件只传递 ID,让子组件自己获取数据 -->
<UserArticleDisplay :article-id="articleId" />

子组件内部:

<script setup>
const props = defineProps<{ articleId: string }>()
const article = ref(null)

watch(() => props.articleId, async (id) => {
  if (id) {
    article.value = await fetchArticle(id)
  }
}, { immediate: true })
</script>

效果:子组件只渲染一次(数据加载完成后直接渲染真实内容,中间用 loading 态占位)。

设计困境:副作用归属问题

传递的内容 副作用的承担者 渲染次数 副作用可见性
title / description(数据) 父组件 2 次 副作用在父组件,透明
articleId(标识符) 子组件 1 次 副作用被子组件隐藏,不透明

传递 articleId 意味着:子组件不仅接收一个 ID,还被默认有能力、有责任去获取数据并处理网络请求。这相当于将副作用责任从父组件转移到了子组件。

更深层的矛盾:声明式与命令式的冲突

Vue 本质上是声明式的:开发者声明“UI 应该是什么样”,框架帮助实现。

但网络请求本质上是命令式的:在某个时刻“命令”组件去获取数据。

当传递 articleId 时,实际上是在声明式的外壳里隐藏了一个命令式的副作用:

<!-- 从代码上看是声明 -->
<Article :article-id="id" />

<!-- 实际运行时等价于命令 -->
<Article @mount="fetchArticle(id)" @update:id="fetchArticle(newId)" />

这是声明式 UI 与命令式副作用之间的一个固有矛盾。没有绝对正确的答案,只有基于具体场景的权衡。


解决方案的分类与取舍

方案一:显式副作用设计

明确告知子组件需要产生副作用,并暴露钩子供父组件参与。

<Article 
  :article-id="id" 
  :fetch-on-mount="true"
  @loading="showSpinner"
  @error="handleError"
/>

设计立场:副作用是必要的,但必须可见、可控。使用者清楚知道这个组件会发起网络请求。

方案二:副作用保留在父组件,保持子组件纯净

保持子组件为纯展示组件,父组件负责所有数据获取。

<!-- 父组件获取数据,子组件只负责渲染 -->
<Article :title="title" :description="description" />

配合 v-if 缓解二次渲染:

<Article
  v-if="articleTitle && articleDescription"
  :title="articleTitle"
  :description="articleDescription"
/>

设计立场:子组件应该是可预测的纯函数。二次渲染是声明式 UI 的合理代价,可以通过条件渲染避免。

方案三:提取独立服务层

// 独立的 ArticleService
const articleService = useArticleService()

// 父组件调用服务,把结果传给子组件
const { data: article, execute } = useAsyncState(
  () => articleService.fetch(id),
  null
)
<Article :data="article" v-if="article" />

设计立场:副作用既不在父组件也不在子组件,而在独立的服务层。这是最符合关注点分离原则的方案。

方案四:接受双重渲染,优化中间状态

承认异步 Props 必然导致多次渲染,但把中间状态(loading/error)作为一等公民暴露出来。

<Article :article-id="id">
  <template #loading>加载中...</template>
  <template #error="{ retry }">加载失败,<button @click="retry">重试</button></template>
</Article>

设计立场:与其隐藏副作用,不如将其显式化、可定制化,让使用者拥有更好的控制权。


如何做出选择

在组件设计时,需要明确回答三个问题:

  1. 谁负责发起副作用?(父组件?子组件?服务层?)
  2. 副作用的可见性如何?(用户是否应该看到 loading?其他开发者是否应该知道组件会发请求?)
  3. 可测试性优先还是渲染次数优先?
优先级 推荐方案 副作用归属
子组件纯净、易测试 传递数据,接受二次渲染 + v-if 父组件
子组件自包含、减少渲染 传递 ID,子组件自治,暴露 loading/error 子组件(显式声明)
架构清晰、可维护 独立服务层 + 传递数据 服务层
用户体验优先 传递 ID + 子组件智能加载(骨架屏 + 一次渲染) 子组件

何时不必过度设计

以下场景中,最简单的方案(即最初的双重渲染方案)完全够用:

  • 子组件非常轻量,二次渲染开销可忽略。
  • 产品明确需要 loading 状态作为用户体验的一部分。
  • 数据请求速度极快(有缓存或 Service Worker),用户感知不到两次渲染。

在这些场景下,无需引入复杂的设计模式。


结语

Vue 响应式系统与异步数据流结合时,ref 的初始值与最终值必然导致响应式派发更新。这不是 Vue 的设计缺陷,而是声明式 UI 框架的固有特性。

真正的组件设计不是消灭副作用或消灭二次渲染,而是:

  1. 明确决定副作用归属于谁
  2. 让这个决定在代码中显而易见
  3. 根据场景选择在哪个环节承担中间状态(父组件、子组件、服务层,或 Suspense)

传递 articleId 确实会将副作用责任转移给子组件。这本身不是错误——前提是开发者有意识地做出这个选择,并理解其代价(可测试性降低、副作用隐藏)。

优秀的组件设计在于理解每个决策的含义后,做出符合当前场景的权衡。


快速参考

场景 推荐方案
子组件渲染开销大,需要避免二次渲染 v-if 就绪后渲染
子组件有独立的数据获取逻辑 传递 ID + 显式 loading/error 钩子
需要 loading 态作为产品需求 保留两次渲染,优化默认占位内容
追求架构清晰、组件可复用 独立服务层 + 传递数据
极致性能,数据返回极快 使用 Suspense 或预取数据

彻底弄懂async/await!解决回调地狱,Vue异步开发必备(超全实战)

一、async/await 核心简介

async/await 是 ES7 推出的异步语法糖,基于 Promise 封装,彻底解决了传统 Promise 链式调用代码嵌套层级深、可读性差、维护困难的「回调地狱」问题。

该语法可以让异步代码像同步代码一样自上而下顺序执行,逻辑清晰、调试简单,是 Vue 项目接口请求、异步逻辑处理的主流规范写法。

核心语法规则

  • async:修饰函数,写在函数定义前,标识该函数为异步函数,返回值为 Promise 对象
  • await:修饰异步操作,只能在 async 函数内部使用,作用是阻塞代码,等待异步请求执行完成后,再执行后续代码
  • 异常捕获:await 异步报错无法直接捕获,必须搭配 try/catch 捕获异常

二、传统 Promise 链式调用(弊端演示)

在多个接口串行依赖调用场景下(后一个接口依赖前一个接口的返回数据),传统 Promise 的 then 链式调用会出现多层嵌套,代码冗余、层级混乱、极难维护。

业务场景:先通过手机号获取用户属地,再根据属地省市信息请求充值面额列表

methods: {
  // 获取用户所属属地
  getLocation(phoneNum) {
    return axios.post('/location', { phoneNum });
  },
  // 根据属地省市获取充值面额列表
  getFaceList(province, city) {
    return axios.post('/location', { province, city });
  },
  // 传统Promise链式调用(多层嵌套,可读性差)
  getFaceResult() {
    this.getLocation(this.phoneNum).then(res => {
      if (res.status === 200 && res.data.success) {
        let province = res.data.province;
        let city = res.data.city;
        // 二次嵌套调用,层级堆积
        this.getFaceList(province, city).then(res => {
          if (res.status === 200 && res.data.success) {
            this.faceList = res.data
          }
        })
      }
    }).catch(err => {
      console.log(err)
    })
  }
}

传统写法痛点:多接口串行嵌套、代码层级深、逻辑割裂、异常处理集中、后续扩展难度大。

三、async/await 标准优雅写法(推荐生产使用)

使用 async/await 重构后,异步逻辑完全同步化,代码扁平化无嵌套,执行顺序一目了然,完美适配接口串行依赖场景。

methods: {
  // 获取用户所属属地
  getLocation(phoneNum) {
    return axios.post('/location', { phoneNum });
  },
  // 根据属地省市获取充值面额列表
  getFaceList(province, city) {
    return axios.post('/location', { province, city });
  },
  // async/await 优雅串行调用
  async getFaceResult() {
    // 所有异步操作统一捕获异常
    try {
      // 等待第一个接口执行完成,获取返回结果
      let location = await this.getLocation(this.phoneNum);
      // 上一个接口执行完毕,才会执行后续逻辑
      if (location.data.success) {
        let province = location.data.province;
        let city = location.data.city;
        // 等待第二个依赖接口执行完成
        let result = await this.getFaceList(province, city);
        if (result.data.success) {
          this.faceList = result.data;
        }
      }
    } catch (err) {
      // 统一处理所有异步异常
      console.log(err);
    }
  }
}

四、核心执行逻辑解析

  1. 给函数添加 async 修饰,将普通函数转为异步函数,支持内部使用 await;
  2. await 强制阻塞代码执行,等待 getLocation 接口请求完毕、返回结果后,才会向下执行;
  3. 解析第一个接口返回的省市数据,作为第二个接口的请求参数;
  4. 再次通过 await 等待 getFaceList 接口执行完成,最终赋值渲染数据;
  5. 所有异步请求的报错、异常,全部被 try/catch 统一捕获,避免页面报错崩溃。

五、async/await 关键使用规则(必记)

  • await 必须在 async 函数内部使用,普通函数内直接使用 await 会报语法错误;
  • await 默认串行执行,代码自上而下依次执行,天然适配接口依赖场景;
  • 必须搭配 try/catch:Promise 链式可单独 catch,await 异步异常无法自动捕获,不加 try/catch 会导致程序报错中断;
  • async 函数始终返回 Promise 对象,可正常搭配 then 继续链式调用,兼容性极强;
  • 无依赖的并行接口不建议串行 await,会造成不必要的请求耗时。

六、核心优势总结

  • 代码扁平化:彻底消除回调嵌套,告别回调地狱,代码整洁优雅;
  • 逻辑更清晰:异步代码同步化写法,执行顺序直观,可读性大幅提升;
  • 维护性更强:新增、删减异步逻辑无需调整嵌套层级,迭代成本低;
  • 异常统一处理:通过 try/catch 集中捕获所有异步异常,报错管理更规范。

七、场景选型总结

  • 接口串行依赖、多步骤异步逻辑:优先使用 async/await(本文核心场景);
  • 简单单次异步请求:可使用简易 Promise then 写法;
  • 无依赖并行请求:搭配 Promise.all + async/await 实现最优性能。

逐步搞懂 Vue 的 patchChildren,把 Diff 算法拆给你看

Vue 的 patchChildren一文看懂

在啃 Vue3 源码的时候,翻到 patchChildren 这一块直接卡住了。网上搜了一圈,要么上来就丢一堆概念,要么就是贴一整段源码说"自己看"。折腾了一段时间,终于把这块逻辑从头到尾捋顺了,索性写篇文章记录一下,也给正在啃源码的朋友搭把手。

说实话,Diff 算法听起来挺唬人,但拆开来看其实就是一件事——页面更新的时候,怎么用最小的代价把旧页面变成新页面。而 patchChildren 就是干这件事的核心函数。

这篇文章我会从最简单的版本开始,一步一步往上加功能,每一步都能跑通、能理解。跟着看完,你对 Vue 的子节点更新逻辑基本就能了然于胸了。


先搞清楚 patchChildren 是干嘛的

在讲代码之前,先说个前提。

Vue 更新页面的时候,不会直接操作真实 DOM。它会维护一份"虚拟 DOM"(就是用 JS 对象描述页面结构),然后对比新旧虚拟 DOM 的差异,最后只把有变化的部分更新到真实 DOM 上。

patchChildren 就是负责更新某个父元素下面所有子节点的函数。它接收三个参数:

  • n1:旧的虚拟 DOM 节点
  • n2:新的虚拟 DOM 节点
  • container:真实的 DOM 容器(就是页面上的那个父元素)

一句话概括它的职责:对比新旧子节点,该更新的更新,该新增的新增,该删的删。


第一版:最简粗暴的更新

我们先看一个最基础的版本,只考虑"新旧子节点数量一样"的情况:

function patchChildren(n1, n2, container) {
  // 新子节点是纯文本
  if (typeof n2.children === 'string') {
    // 文本更新逻辑,先不管
  }
  // 新子节点是数组(多个标签)
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children

    // 按下标一一对比,逐个更新
    for (let i = 0; i < oldChildren.length; i++) {
      patch(oldChildren[i], newChildren[i])
    }
  }
  else {
    // 新无子节点,清空逻辑,先不管
  }
}

逻辑很简单粗暴——旧节点有几个,新节点就有几个,按下标顺序挨个调用 patch 更新。

patch 是 Vue 里负责单个节点更新的函数:标签一样就改内容,标签不一样就销毁旧的创建新的。

这个版本能跑,但问题也很明显:如果新旧子节点数量不一样呢? 多出来的怎么办?少了的怎么办?


第二版:加上新增和删除

接下来我们把逻辑补全,处理子节点数量不一致的情况:

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略文本处理
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children
    const oldLen = oldChildren.length
    const newLen = newChildren.length
    // 取较短的长度,算出能一一对应的部分
    const commonLength = Math.min(oldLen, newLen)

    // 第一步:能对上的,原地更新
    for (let i = 0; i < commonLength; i++) {
      patch(oldChildren[i], newChildren[i], container)
    }

    // 第二步:新节点更多 → 多出来的要挂载
    if (newLen > oldLen) {
      for (let i = commonLength; i < newLen; i++) {
        patch(null, newChildren[i], container)
      }
    }
    // 第三步:旧节点更多 → 多出来的要卸载
    else if (oldLen > newLen) {
      for (let i = commonLength; i < oldLen; i++) {
        unmount(oldChildren[i])
      }
    }
  }
  else {
    // 省略
  }
}

拆开来看这三步:

第一步,先把能一一对应的子节点更新了。比如旧的有 3 个,新的有 5 个,那前 3 个先挨个更新。

第二步,新的比旧的多,多出来的那些调用 patch(null, 新节点)。第一个参数传 null 意味着"没有旧节点",所以会直接创建新的真实 DOM 挂载到页面上。

第三步,旧的多新的少,多出来的旧节点调用 unmount 直接从页面删掉。

举个具体例子感受一下:

原来页面有 div1、div2,更新后要变成 div1、div2、div3、div4

  • 前 2 个原地更新
  • 后 2 个是全新的,新建挂载

反过来:

原来页面有 div1、div2、div3,更新后只要 div1

  • 第 1 个原地更新
  • 后 2 个旧节点直接删除

到这一步,基本的增删改都能处理了。但还有一个大问题——它只按下标顺序比对。如果子节点只是换了顺序(比如列表排序),它不会聪明地移动 DOM,而是全部删掉重建,性能很差。

这就是为什么 Vue 需要引入 key


第三版:引入 key,实现 DOM 复用

用过 Vue 的都知道写 v-for 要加 :key,但很多人可能不太清楚它底层到底干了什么。看这段代码就明白了:

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children

    // 遍历每一个新子节点
    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i]

      // 拿着新节点去旧节点里找 key 一样的
      for (let j = 0; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]

        if (newVNode.key === oldVNode.key) {
          // key 相同 → 是同一个元素,复用旧 DOM,只更新内容
          patch(oldVNode, newVNode, container)
          break // 找到了就别找了,处理下一个
        }
      }
    }
  }
}

key 就是每个节点的"身份证号"。身份证一样,就说明是同一个元素,只是内容变了,不需要删掉重建,直接在原来的 DOM 上改就行。

打个比方:

旧页面有 3 个人:甲(key=1)、乙(key=2)、丙(key=3) 新页面要变成:乙(key=2)、甲(key=1)、丁(key=4)

执行过程:

  1. 拿新人"乙"去旧人里找,找到 key=2 的乙 → 不换人,直接给旧乙换身衣服(更新数据)
  2. 拿新人"甲"去旧人里找,找到 key=1 的甲 → 同理原地更新
  3. 拿新人"丁"去旧人里找,找不到 → 这是新来的,需要另外处理(后面会说)

你看,甲和乙只是换了顺序,但因为 key 能对上,DOM 直接复用,不用销毁重建。这就是 key 的核心价值。

不过这个版本还有个问题——它能复用 DOM,但不会移动 DOM 的位置。也就是说,虽然旧乙的 DOM 被复用了,但它在页面上的物理位置没变,视觉上顺序还是错的。

所以我们需要进一步优化。


第四版:lastIndex 判断是否需要移动

这版加了一个关键变量 lastIndex,用来记录上一个被复用的节点在旧数组里的位置。通过比较当前位置和上次位置,就能判断出元素是不是"往前挪了":

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children
    let lastIndex = 0 // 记录旧节点中最大下标

    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i]

      for (let j = 0; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]

        if (newVNode.key === oldVNode.key) {
          patch(oldVNode, newVNode, container)

          if (j < lastIndex) {
            // 当前旧下标 < 上次最大下标
            // 说明这个元素往前挪了,需要移动 DOM
          } else {
            // 顺序正常,不用移动,更新最大下标
            lastIndex = j
          }
          break
        }
      }
    }
  }
}

这个 j < lastIndex 的判断是整段逻辑的灵魂,我用一个例子帮你理清:

旧 key 顺序:1、2、3 新 key 顺序:3、1、2

执行过程:

  1. 处理新 key=3:在旧数组里找到 j=2,2 >= lastIndex(0),顺序正常,不移动,lastIndex 更新为 2
  2. 处理新 key=1:在旧数组里找到 j=1,1 < lastIndex(2),说明这个元素本来在后面,现在跑到前面了 → 需要移动 DOM
  3. 处理新 key=2:在旧数组里找到 j=2,同样 2 < lastIndex(2) 不成立... 等等,这里 j=2 等于 lastIndex=2,所以不移动,lastIndex 更新为 2

嗯,你可能会问:判断出需要移动之后,具体怎么移?这就是下一版要解决的问题。


第五版:锚点精准插入,移动到正确位置

光知道"要移动"还不够,还得知道"移到哪"。这一版引入了锚点(anchor) 的概念:

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children
    let lastIndex = 0

    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i]
      let find = false // 标记是否找到可复用的旧节点

      for (let j = 0; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]

        if (newVNode.key === oldVNode.key) {
          find = true
          patch(oldVNode, newVNode, container)

          if (j < lastIndex) {
            // 需要移动:找到新顺序里的前一个兄弟节点
            const prevVNode = newChildren[i - 1]
            if (prevVNode) {
              // 锚点 = 前一个节点的下一个兄弟元素
              const anchor = prevVNode.el.nextSibling
              // 把当前 DOM 插到锚点前面 = 放到前一个节点的后面
              insert(newVNode.el, container, anchor)
            }
          } else {
            lastIndex = j
          }
          break
        }
      }

      // find 为 false:旧节点里没找到 → 这是新增节点
      if (!find) {
        const prevVNode = newChildren[i - 1]
        let anchor = null

        if (prevVNode) {
          // 有前兄弟节点,插到它后面
          anchor = prevVNode.el.nextSibling
        } else {
          // 没有前兄弟,说明是第一个子元素,插到最前面
          anchor = container.firstChild
        }
        // 创建新 DOM 并挂载到锚点位置
        patch(null, newVNode, container, anchor)
      }
    }
  }
}

这里有两块新逻辑,我分开说。

移动 DOM 的具体操作

当判断出 j < lastIndex 需要移动时:

  1. 先找到当前节点在新顺序里的前一个兄弟节点 prevVNode
  2. 拿到前一个兄弟节点的真实 DOM 的下一个兄弟元素作为锚点 anchor
  3. 调用 insert 把当前 DOM 插到锚点前面

说白了就是:我要站到前一个兄弟的后面。通过"前一个兄弟的下一个元素"作为锚点,就能精确定位。

新增节点的处理

注意这里多了一个 find 变量。内层循环跑完如果 find 还是 false,说明这个新节点在旧节点里完全找不到同 key 的,那就是个全新元素。

新增的时候同样需要锚点来决定插在哪:

  • 有前兄弟节点 → 插到前兄弟后面
  • 没有前兄弟(自己是第一个) → 插到容器最前面

patch(null, newVNode, container, anchor) 里第一个参数传 null,代表没有旧节点,走的是挂载逻辑,会创建新的真实 DOM。

顺便说一下,patch 函数本身也做了对应改造来支持锚点:

function patch(n1, n2, container, anchor) {
  if (typeof n2.type === 'string') {
    if (!n1) {
      // 全新节点,挂载时带上锚点
      mountElement(n2, container, anchor)
    } else {
      // 有旧节点,走更新逻辑
      patchElement(n1, n2)
    }
  }
  // ...其他类型省略
}

mountElement 内部调用 insert(el, container, anchor),不传锚点就默认追加到最后,传了就插到锚点前面。


第六版:补齐最后一块拼图——删除多余旧节点

前面处理了复用、移动、新增,还差一个:旧的节点里有些在新列表里已经不存在了,需要删掉

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children
    let lastIndex = 0

    // ...前面复用、移动、新增的逻辑(和上一版一样)
    for (let i = 0; i < newChildren.length; i++) {
      // ...(同上,省略)
    }

    // ========== 新增:遍历旧节点,清理不需要的 ==========
    for (let i = 0; i < oldChildren.length; i++) {
      const oldVNode = oldChildren[i]
      // 拿旧节点的 key 去新列表里找
      const has = newChildren.find(vnode => vnode.key === oldVNode.key)

      if (!has) {
        // 新列表里找不到这个 key → 这个旧节点不需要了,删掉
        unmount(oldVNode)
      }
    }
  }
}

逻辑很直白:遍历所有旧节点,拿着它的 key 去新列表里找,找不到就说明新页面已经不需要它了,直接 unmount 删掉。

再举个完整的例子把所有逻辑串起来:

旧 key:1、2、3 新 key:3、1、4

执行过程:

  1. key=3:旧里找到,复用 DOM,顺序正常不移动
  2. key=1:旧里找到,j < lastIndex,触发移动
  3. key=4:旧里找不到,find=false,判定为新增,创建并插入
  4. 清理阶段:遍历旧节点 1、2、3
    • key=1:新里有 → 保留
    • key=2:新里没有 → unmount 删除
    • key=3:新里有 → 保留

最终结果:key=2 被清理,key=4 被新增,key=1 和 key=3 被复用并移动到正确位置。整个更新过程没有多余的 DOM 创建和销毁。


回顾一下完整流程

到这里,patchChildren 的核心逻辑就完整了。我用一张流程图帮你把所有分支串起来:

patchChildren 被调用
  │
  ├─ 新子节点是文本 → 走文本更新逻辑
  │
  ├─ 新子节点是数组 → 进入核心 Diff
  │   │
  │   ├─ 遍历新节点,用 key 去旧节点里匹配
  │   │   │
  │   │   ├─ 找到了(find=true)
  │   │   │   ├─ 复用旧 DOM,patch 更新内容
  │   │   │   ├─ j < lastIndex → 移动 DOM 到正确位置
  │   │   │   └─ j >= lastIndex → 不移动,更新 lastIndex
  │   │   │
  │   │   └─ 没找到(find=false)→ 新增节点,锚点精准插入
  │   │
  │   └─ 遍历旧节点,清理新列表中不存在的 → unmount 删除
  │
  └─ 新无子节点 → 清空容器

总结成一句话:能复用就复用,该移动就移动,多了就新增,少了就删除。

这就是 Vue 简易版 Diff 子节点更新的全部核心逻辑。当然,Vue3 实际源码里用的是更高效的快速 Diff 算法(基于最长递增子序列),但核心思想是一脉相承的。搞懂了这个简易版,再看源码里的完整实现会轻松很多。


最后说两句

啃源码这件事,说实话一开始挺痛苦的,尤其是 Diff 这块,变量多、嵌套深,很容易看着看着就迷失了。但如果你能像我这样,从最简单的版本开始,一步一步往上加功能,每一步都搞清楚"为什么要这样写",其实也没那么难。

希望这篇文章能帮到正在啃 Vue 源码的你。如果觉得有帮助,欢迎点赞收藏,有问题也欢迎在评论区交流。


参考:Vue.js 设计与实现 —— 霍春阳

Vue生命周期速查:Vue2+Vue3钩子全解析,新手也能秒懂

Vue生命周期,本质是Vue实例从创建到销毁的完整过程,每个阶段都会触发对应的钩子函数(生命周期钩子),开发者可通过这些钩子,在不同时机执行所需逻辑(如初始化数据、操作DOM、清理资源等)。核心分为「创建、挂载、更新、销毁」四大阶段,Vue2与Vue3的生命周期整体逻辑一致,但钩子名称、使用方式有差异,以下分版本详细解析,兼顾理论与实操,让新手也能快速上手、高效运用。

一、先明确核心:生命周期四大阶段

无论Vue2还是Vue3,生命周期都围绕「实例从生到死」展开,四大核心阶段不可逆,每个阶段的钩子函数只执行一次(更新阶段除外,可多次触发):

  1. 创建阶段:实例从无到有,初始化数据、配置,未挂载DOM;
  2. 挂载阶段:实例挂载到DOM树上,生成真实DOM,可开始操作DOM;
  3. 更新阶段:响应式数据发生变化,触发DOM重新渲染,可多次执行;
  4. 销毁阶段:实例被销毁,清理资源,避免内存泄漏。

二、Vue2 生命周期(选项式API,最常用)

Vue2使用选项式API,生命周期钩子共8个,按执行顺序排列,无需额外导入,直接写在组件选项中即可使用,每个钩子的作用清晰,适配日常开发场景。

1. 创建阶段(实例初始化,未挂载DOM)

核心:初始化实例、数据观测,此时DOM尚未生成,无法操作DOM。

  • beforeCreate(创建前) :实例刚被创建,data、methods、computed等尚未初始化,无法访问this.data、this.methods,几乎不用(仅特殊场景初始化非响应式数据)。
  • created(创建后) :实例创建完成,data、methods、computed已初始化,可访问响应式数据,但DOM未挂载($el为undefined),常用场景:发起初始化接口请求、初始化数据处理。

2. 挂载阶段(实例挂载到DOM,可操作DOM)

核心:将实例渲染到页面,生成真实DOM,此时可正常操作DOM。

  • beforeMount(挂载前) :模板已编译完成,但尚未挂载到DOM树上,$el已存在(虚拟DOM),但真实DOM未生成,仍无法操作真实DOM。
  • mounted(挂载后) :实例已完全挂载到DOM树上,真实DOM已生成,可正常操作DOM(如获取DOM元素、初始化第三方插件),是最常用的钩子之一。

3. 更新阶段(响应式数据变化,可多次触发)

核心:当data中的响应式数据发生变化时,触发该阶段,仅更新变化的部分,无需重新渲染整个页面。

  • beforeUpdate(更新前) :响应式数据已变化,但DOM尚未重新渲染,可获取变化前的DOM状态,常用场景:更新前保存DOM原有状态。
  • updated(更新后) :DOM已根据变化后的响应式数据重新渲染,可获取更新后的DOM状态,注意:避免在该钩子中修改响应式数据(会导致无限循环更新)。

4. 销毁阶段(实例销毁,清理资源)

核心:实例被销毁(如组件卸载),需清理资源,避免内存泄漏。

  • beforeDestroy(销毁前) :实例即将被销毁,仍可访问实例的所有属性和方法,常用场景:清理资源(清除定时器、取消接口请求、解绑事件监听)。
  • destroyed(销毁后) :实例已完全销毁,所有属性、方法、事件监听均被解绑,DOM仍存在(需手动清理),几乎不用(清理工作优先在beforeDestroy中完成)。

Vue2 生命周期执行顺序(必记)

beforeCreate → created → beforeMount → mounted → (数据变化)beforeUpdate → updated → (实例销毁)beforeDestroy → destroyed

三、Vue3 生命周期(选项式+组合式API,推荐)

Vue3兼容Vue2的选项式API(生命周期用法与Vue2一致),但更推荐使用组合式API(setup语法糖),组合式API的生命周期钩子需手动导入,名称以“on”开头,核心逻辑与Vue2完全一致,只是使用方式更灵活、轻量化。

1. Vue3 选项式API(与Vue2兼容)

用法和Vue2完全一致,仅替换2个钩子名称(语义更准确),其余钩子功能、执行顺序完全相同:

  • beforeUnmount 替代 Vue2 的 beforeDestroy;
  • unmounted 替代 Vue2 的 destroyed。
<script>
export default {
  data() {
    return { count: 0 }
  },
  beforeCreate() { /* 实例创建前 */ },
  created() { /* 实例创建后 */ },
  beforeMount() { /* 挂载前 */ },
  mounted() { /* 挂载后 */ },
  beforeUpdate() { /* 更新前 */ },
  updated() { /* 更新后 */ },
  beforeUnmount() { /* 卸载前(替代beforeDestroy) */ },
  unmounted() { /* 卸载后(替代destroyed) */ }
}
</script>

2. Vue3 组合式API(setup语法糖,推荐)

组合式API的生命周期钩子需从vue中导入,名称以“on”开头,与Vue2的钩子一一对应,函数式调用,更贴合组合式开发逻辑,常用钩子如下(按执行顺序):

  • onBeforeMount:对应Vue2的beforeMount,挂载前执行;
  • onMounted:对应Vue2的mounted,挂载后执行(最常用);
  • onBeforeUpdate:对应Vue2的beforeUpdate,更新前执行;
  • onUpdated:对应Vue2的updated,更新后执行;
  • onBeforeUnmount:对应Vue2的beforeDestroy,卸载前执行(最常用,清理资源);
  • onUnmounted:对应Vue2的destroyed,卸载后执行。

补充:Vue3新增2个调试用钩子(onRenderTracked、onRenderTriggered),日常开发几乎不用,仅用于调试响应式数据的渲染跟踪。

Vue3 组合式API 实操示例(setup语法糖)

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const count = ref(0)
let timer = null

// 挂载后:初始化定时器(常用场景)
onMounted(() => {
  timer = setInterval(() => {
    count.value++
  }, 1000)
})

// 卸载前:清理定时器(避免内存泄漏,常用场景)
onBeforeUnmount(() => {
  clearInterval(timer)
})
</script>

四、Vue2与Vue3生命周期核心差异(重点)

对比维度 Vue2 Vue3
API类型 仅支持选项式API 兼容选项式,推荐组合式(setup)
钩子名称(销毁阶段) beforeDestroy、destroyed beforeUnmount、unmounted(选项式);onBeforeUnmount、onUnmounted(组合式)
钩子使用 无需导入,直接写在组件选项中 组合式需导入,函数式调用,更灵活
核心优势 兼容旧项目,逻辑直观,上手简单 轻量化,开发效率高,支持调试钩子

五、生命周期实操注意事项

  • 操作DOM的时机:仅能在mounted(Vue2/Vue3)、updated(Vue2/Vue3)中操作真实DOM,beforeMount、beforeUpdate中无法操作(未生成/未更新)。
  • 避免内存泄漏:定时器、事件监听、第三方插件实例,必须在beforeDestroy(Vue2)/onBeforeUnmount(Vue3)中清理,否则会导致内存泄漏。
  • 接口请求时机:初始化请求可在created(Vue2)/setup中(Vue3)、mounted中发起;created中发起请求更早,但无法操作DOM;mounted中发起可结合DOM操作。
  • 组件复用:每个组件实例的生命周期独立执行,互不影响,比如多个相同组件,各自的钩子函数分别触发。

六、总结(一句话记牢)

Vue生命周期就是“实例从创建到销毁”的全过程,核心是四大阶段+对应钩子,Vue2侧重选项式、兼容旧项目,Vue3兼容选项式、推荐组合式,记住“挂载后操作DOM、销毁前清理资源”,就能覆盖90%的开发场景,新手也能快速上手。

我把Vue2响应式源码从头到尾啃了一遍,这是整理笔记

Vue 2 响应式源码精读:从 initState 到 defineReactive

之前看 Vue 源码的时候,状态初始化这块一直是一知半解的状态,后来硬着头皮一行行啃下来,发现其实逻辑很清晰。这篇就把 initState、initProps、initData、proxy、observe、Observer、defineReactive 这几个核心函数串起来讲,争取让读完的人都能在脑子里画出整条链路。


一、initState —— 所有状态的"总调度"

initState 这个函数做的事情说白了就是:把 Vue 实例上的 props、methods、data、computed、watch 统统初始化一遍,变成响应式数据。

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options

  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)

  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }

  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

拆开看:

  • vm._watchers = [] —— 先准备一个数组,后面所有 Watcher(computed、watch、渲染 watcher)都会塞进去
  • const opts = vm.$options —— 就是你 new Vue({ ... }) 传进来的配置对象,取出来方便后面用
  • 后面就是按顺序依次初始化:props → methods → data → computed → watch

这个顺序不是随便排的。 props 先初始化,所以 data 里能访问 props;methods 第二,所以 data 里能调 methods;computed 第四,所以它能依赖 data 和 props;watch 最后,所以它能监听前面所有的数据。谁在前谁在后,是有依赖关系的。

data 那块有个细节:如果用户没写 data,Vue 会给一个空对象 {} 并调 observe,保证根实例一定有响应式数据。


二、initProps —— 处理父组件传进来的数据

initProps 要干的事情:拿到父组件传的值 → 校验类型和默认值 → 变成响应式 → 代理到 this 上。

function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent

  if (!isRoot) {
    toggleObserving(false)
  }

  for (const key in propsOptions) {
    keys.push(key)
    const value = validateProp(key, propsOptions, propsData, vm)

    // 省略了开发环境警告逻辑...

    defineReactive(props, key, value, () => {
      if (vm.$parent && !isUpdatingChildComponent) {
        warn(`Avoid mutating a prop directly...`)
      }
    })

    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }

  toggleObserving(true)
}

几个关键点:

1. propsData vs propsOptions

  • propsData 是父组件实际传过来的值,比如 <Child msg="hello"/> 中的 { msg: 'hello' }
  • propsOptions 是子组件声明的 props 配置,props: { msg: { type: String } }

2. toggleObserving(false) 是干嘛的?

非根组件会先关掉响应式转换开关。因为 props 的值来自父组件,父组件那边已经做过响应式处理了,子组件不需要再 observe 一遍,避免重复。

3. validateProp

这个函数负责校验:取父组件传入的值,没传就用默认值,检查类型对不对,执行自定义校验函数,最后返回合法值。

4. defineReactive 里的第四个参数

defineReactive(props, key, value, () => {
  if (vm.$parent && !isUpdatingChildComponent) {
    warn(`Avoid mutating a prop directly...`)
  }
})

这个箭头函数是自定义 setter,当你在子组件里直接改 props(this.msg = 'xxx')的时候会触发警告。这就是为什么 Vue 一直强调"不要在子组件里直接修改 props"——源码层面就给你拦着了。

5. proxy(vm, '_props', key)

让你能直接写 this.msg 而不是 this._props.msg,后面会单独讲 proxy 函数。


三、initData —— 处理组件自身的数据

initData 的流程:拿到 data → 处理函数/对象 → 挂载到 vm._data → 校验重名 → 代理到 this → observe 变响应式。

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}

  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object...',
      vm
    )
  }

  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length

  while (i--) {
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(`Method "${key}" has already been defined as a data property.`, vm)
      }
    }
    if (props && hasOwn(props, key)) {
      warn(`The data property "${key}" is already declared as a prop.`, vm)
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }

  observe(data, true /* asRootData */)
}

几个要注意的地方:

1. 组件的 data 为什么必须是函数?

data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {}

这行就是答案。组件会被复用创建多个实例,如果 data 是对象,所有实例共享同一块内存,一个改了全跟着变。用函数的话每次 getData 都返回新对象,实例之间数据隔离。

2. 校验很严格

遍历 data 的每个 key,检查三件事:

  • 不能和 methods 重名(否则 this.xxx 不知道是取数据还是调方法)
  • 不能和 props 重名(props 优先级更高,重名会被覆盖)
  • 不能是 $_ 开头的保留字(Vue 内部属性用的)

3. 最后一步 observe(data, true)

把整个 data 对象递归地变成响应式,这是响应式的入口,后面会细讲。

对比一下 initProps 和 initData:

initProps initData
数据存哪 vm._props vm._data
怎么访问 this.xxx(代理) this.xxx(代理)
响应式方式 defineReactive 逐个属性 observe 整体递归
数据来源 父组件传入 组件自己定义
能不能改 子组件不能改 可以改

四、proxy —— this.xxx 背后的"中间商"

这个函数特别短,但特别关键。它做的事情就一件:让你写 this.xxx 的时候,实际去访问 this._data.xxxthis._props.xxx

const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

逻辑很直白:

  1. 先定义一个公用的属性描述符模板 sharedPropertyDefinition,不用每次都 new 一个,省内存
  2. 动态设置 getter:读 this.msg → 实际读 this._data.msg(或 this._props.msg
  3. 动态设置 setter:写 this.msg = 'hi' → 实际写 this._data.msg = 'hi'
  4. Object.defineProperty 把这个属性挂到 Vue 实例上

所以 this.xxx 本身不存任何数据,它就是一个"门把手",拧开之后通向 _data_props

Vue 这么设计有几个好处:

  • 写法简洁,不用到处写 this._data.xxx
  • 真实数据藏在内部,外部只暴露代理接口,内部怎么优化不影响用户代码
  • 不管是 data、props 还是 computed,用户都只需要 this.xxx 一种写法

五、observe —— 响应式的"门卫"

observe 是响应式系统的入口函数,负责判断一个值需不需要、能不能变成响应式。

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }

  let ob: Observer | void

  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }

  if (asRootData && ob) {
    ob.vmCount++
  }

  return ob
}

分三步看:

第一步:过滤掉不需要处理的值

不是对象或者数组?直接 return。是 VNode(虚拟 DOM)?也 return。简单类型(string、number、boolean)不需要劫持。

第二步:检查是不是已经处理过了

__ob__ 是 Vue 给响应式对象加的隐藏标记。如果对象上已经有 __ob__,说明已经被 observe 过了,直接复用,不重复创建。这是个重要的性能优化。

第三步:满足五个条件才创建 Observer

shouldObserve &&              // 响应式开关是开着的
!isServerRendering() &&       // 不是服务端渲染
(Array.isArray(value) || isPlainObject(value)) && // 是对象或数组
Object.isExtensible(value) && // 没被 Object.freeze() 冻结
!value._isVue                 // 不是 Vue 实例本身

五个条件全满足,才会 new Observer(value),真正给数据穿上响应式外套。

最后 ob.vmCount++ 是给根数据打标记,后面组件销毁的时候会用到,跟内存回收有关。


六、Observer —— 真正给数据装监控的"工程师"

observe 只是门卫,Observer 才是干活的人。

export class Observer {
  value: any
  dep: Dep
  vmCount: number

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0

    def(value, '__ob__', this)

    if (Array.isArray(value)) {
      const augment = hasProto ? protoAugment : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

构造函数做了这些事:

1. this.dep = new Dep()

每个被监控的对象都有一个 Dep(依赖管理器),可以理解成一个"通讯录",记录哪些 Watcher 用了这个对象的数据。数据变了就翻通讯录通知。

2. def(value, '__ob__', this)

给数据打上 __ob__ 标记,值就是 Observer 实例本身。用了 def 函数(后面讲),所以这个属性是不可枚举的,for...in 遍历不到,不会污染用户数据。

3. 对象和数组走不同路线

这是 Vue 响应式里最容易考的点:

  • 对象:调 walk,遍历所有属性,逐个调 defineReactive 给每个属性加 getter/setter
  • 数组:重写原型上的 7 个变异方法(pushpopshiftunshiftsplicesortreverse),然后 observeArray 递归处理数组里的每一项

为什么数组要特殊处理?因为 Object.defineProperty 劫持不到数组下标的赋值操作(arr[0] = xxx 不会触发 setter),所以 Vue 只能通过重写那几个会修改数组的方法来"曲线救国"。

这也解释了两个经典面试题:

  • 为什么对象新增属性不响应? 因为 walk 只在初始化时遍历一次,后面加的属性没经过 defineReactive,没有 getter/setter。用 Vue.setthis.$set 就行。
  • 为什么数组下标赋值不响应? 因为 Observer 没有劫持数组下标,只有那 7 个重写方法能触发更新。用 spliceVue.set 替代。

七、def —— 一个极简的工具函数

顺带提一下 def,因为上面用到了:

export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

就是对 Object.defineProperty 的封装,默认不可枚举。Vue 内部用它来给对象加隐藏属性(比如 __ob__),不会出现在 for...inObject.keys() 里。


八、defineReactive —— 响应式的核心加工厂

最后也是最核心的一个函数。defineReactive 的使命:给对象的某个属性劫持 get 和 set,实现"读的时候收集依赖,写的时候派发更新"。

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val)

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,

    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },

    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}

这段代码值得拆细了看。

Getter:读数据的时候发生了什么

get: function reactiveGetter () {
  const value = getter ? getter.call(obj) : val
  if (Dep.target) {
    dep.depend()
    if (childOb) {
      childOb.dep.depend()
      if (Array.isArray(value)) {
        dependArray(value)
      }
    }
  }
  return value
}

当你渲染模板、执行 computed 或 watch 的时候,会读到 this.xxx,就会触发这个 getter。

关键在 Dep.target。它指向当前正在执行的 Watcher(可能是渲染 Watcher、computed Watcher 或 watch Watcher)。如果 Dep.target 存在,说明"有人正在用这个数据",就调 dep.depend() 把这个 Watcher 记录下来。

如果值本身是对象或数组,还要递归地对子对象也收集依赖(childOb.dep.depend()),数组还要额外处理(dependArray)。

一句话:getter 负责"记住谁在用我"。

Setter:改数据的时候发生了什么

set: function reactiveSetter (newVal) {
  const value = getter ? getter.call(obj) : val
  if (newVal === value || (newVal !== newVal && value !== value)) {
    return
  }
  if (process.env.NODE_ENV !== 'production' && customSetter) {
    customSetter()
  }
  if (setter) {
    setter.call(obj, newVal)
  } else {
    val = newVal
  }
  childOb = !shallow && observe(newVal)
  dep.notify()
}

当你执行 this.xxx = 新值,触发 setter:

  1. 先拿旧值,跟新值比一下,一样就直接 returnNaN !== NaN 的特殊情况也处理了),这是性能优化
  2. 开发环境下如果有 customSetter 就调一下(比如 initProps 里传的那个"不要直接改 props"的警告)
  3. 赋新值
  4. 新值如果是对象/数组,也要 observe,保证新数据也是响应式的
  5. dep.notify() —— 遍历之前收集的 Watcher 列表,逐个通知更新

一句话:setter 负责"通知所有用我的人,我变了"。

整个响应式闭环

画个简单的流程:

vue-reactive-flowchart.png


整条链路串起来

到这里,Vue 2 响应式初始化的完整链路就清楚了:

new Vue()
  → initState()
    → initProps()  → validateProp + defineReactive + proxy
    → initMethods()
    → initData()   → getData + 校验 + proxy + observe
    → initComputed()
    → initWatch()

proxy: this.xxx → this._data.xxx / this._props.xxx

observe: 判断要不要响应式 → new Observer()
  Observer:
    对象 → walk → defineReactive(给每个属性加 getter/setter)
    数组 → 重写 7 个变异方法 + observeArray 递归

defineReactive:
  get → dep.depend()(收集依赖)
  set → dep.notify()(派发更新)

每个函数各司其职,代码量不大但设计得很精巧。建议感兴趣的话对着源码自己走一遍,比看任何文章都管用。

Vue 3.x 模板编译优化:静态提升、预字符串化与 Block Tree

Vue 3 在性能上的飞跃,很大程度上归功于编译时(compile-time)的深度优化。Vue 3 的编译器会尽可能多地分析模板,生成更高效的渲染函数代码。本文将从三个核心优化入手——静态提升(含静态节点缓存、静态属性提升、动态属性列表提升)、预字符串化 和 Block Tree

静态提升

静态节点缓存(CACHED /v-once)

  • 完全静态元素 → PatchFlags.CACHED 标记
  • 加入 toCache 列表 → 调用 context.cache()
  • 编译结果:生成 _cache(0)运行时缓存
  • 不是提升到 render 外(不是 _hoisted_1
<template>
  <div>
    <div class="title">show1</div>
    <div>show1</div>
  </div>
</template>
import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [...(_cache[0] || (_cache[0] = [
    _createElementVNode("div", { class: "title" }, "show1", -1 /* CACHED */),
    _createElementVNode("div", null, "show1", -1 /* CACHED */)
  ]))]))
}

静态属性提升(Hoisted Props)

触发条件:节点动态(有文本 / 子节点更新),但 props 全静态

<template>
  <div>
    <div class="title" style="color: red" id="dom" title="show">
      {{ message }}
    </div>
    <div>show1</div>
  </div>
</template>
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = {
  class: "title",
  style: {"color":"red"},
  id: "dom",
  title: "show"
}

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("div", _hoisted_1, _toDisplayString(_ctx.message), 1 /* TEXT */),
    _cache[0] || (_cache[0] = _createElementVNode("div", null, "show1", -1 /* CACHED */))
  ]))
}

动态属性列表提升(Hoisted dynamicProps)

触发条件:任何有动态绑定的节点

<template>
  <div>
    <div
      :title="title"
      :class="title"
      :style="{ color: 'red', borderWidth: borderWidth }"
      :id="dom"
      :data-dom="dom"
    >
      show1
    </div>
    <div>show1</div>
  </div>
</template>
import { normalizeClass as _normalizeClass, normalizeStyle as _normalizeStyle, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = ["title", "id", "data-dom"]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("div", {
      title: _ctx.title,
      class: _normalizeClass(_ctx.title),
      style: _normalizeStyle({ color: 'red', borderWidth: _ctx.borderWidth }),
      id: _ctx.dom,
      "data-dom": _ctx.dom
    }, " show1 ", 14 /* CLASS, STYLE, PROPS */, _hoisted_1),
    _cache[0] || (_cache[0] = _createElementVNode("div", null, "show1", -1 /* CACHED */))
  ]))
}

第四个参数 patchFlag计算 = 2(class) + 4 (style) + 8(非class 、非style)

image.png

源码 walk

vue3-core/packages/compiler-core/src/transforms/cacheStatic.ts

function walk(
  node: ParentNode,
  parent: ParentNode | undefined,
  context: TransformContext,
  doNotHoistNode: boolean = false,
  inFor = false,
) {
  const { children } = node // 获取子节点列表
  // 收集可缓存的静态节点(最终编译为 _cache 缓存)
  const toCache: (PlainElementNode | TextCallNode)[] = []

  for (let i = 0; i < children.length; i++) {
    const child = children[i]
    // only plain elements & text calls are eligible for caching.

    // 一、普通元素节点
    // 只处理普通元素(非组件、非插槽等)
    if (
      child.type === NodeTypes.ELEMENT &&
      child.tagType === ElementTypes.ELEMENT
    ) {
      // 计算节点的常量类型
      const constantType = doNotHoistNode
        ? // 如果 doNotHoistNode 为 true(表示该节点不应被提升)
          ConstantTypes.NOT_CONSTANT
        : getConstantType(child, context)

      /**
          NOT_CONSTANT = 0, // 非常量表达式,在编译时无法确定其值
          CAN_SKIP_PATCH, // 1 可以跳过补丁的常量,通常是静态节点
          CAN_CACHE, // 2 可以缓存的常量,其值在编译时已知
          CAN_STRINGIFY, // 3 可以字符串化的常量,其值可以在编译时转换为字符串
         */
      // ============== 场景1:节点是静态节点 ==============
      if (constantType > ConstantTypes.NOT_CONSTANT) {
        if (constantType >= ConstantTypes.CAN_CACHE) {
          // 如果常量类型达到 CAN_CACHE(意味着节点极其稳定,可被 v-once 缓存)

          // 设置 patchFlag 为 PatchFlags.CACHED (即 -1,表示完全静态)
          ;(child.codegenNode as VNodeCall).patchFlag = PatchFlags.CACHED
          // 节点添加到 toCache 数组中
          toCache.push(child)
          continue
        }

        // ============== 场景2:节点非整体静态,但属性可提升 ==============
      } else {
        // node may contain dynamic children, but its props may be eligible for
        // hoisting.
        // 获取节点的生成节点
        const codegenNode = child.codegenNode!

        // 节点是VNODE_CALL 类型(虚拟节点调用)
        if (codegenNode.type === NodeTypes.VNODE_CALL) {
          const flag = codegenNode.patchFlag

          if (
            (flag === undefined || // 未标记的节点
              flag === PatchFlags.NEED_PATCH || // 需要比对的节点
              flag === PatchFlags.TEXT) && // 只有文本内容会变化的节点
            // 检查节点属性的常量类型
            getGeneratedPropsConstantType(child, context) >=
              ConstantTypes.CAN_CACHE
          ) {
            // 获取节点的属性对象
            const props = getNodeProps(child)
            if (props) {
              // 将属性对象提升到渲染函数外部
              // 更新代码生成节点
              codegenNode.props = context.hoist(props)
            }
          }
          // 动态属性列表(dynamicProps):它是编译器生成的一个静态字符串数组,仅用于记录哪些属性名是动态绑定的。
          // 这个数组本身不依赖任何响应式数据,所以可以被提升。
          if (codegenNode.dynamicProps) {
            codegenNode.dynamicProps = context.hoist(codegenNode.dynamicProps)
          }
        }
      }

      // 处理文本调用节点
    } else if (child.type === NodeTypes.TEXT_CALL) {
      const constantType = doNotHoistNode
        ? ConstantTypes.NOT_CONSTANT
        : // 计算节点的常量类型
          getConstantType(child, context)

      // 纯静态文本节点 → 加入缓存,避免重复生成文本 VNode。
      if (constantType >= ConstantTypes.CAN_CACHE) {
        if (
          // 代码生成节点类型为 JavaScript 调用表达式
          child.codegenNode.type === NodeTypes.JS_CALL_EXPRESSION &&
          child.codegenNode.arguments.length > 0 // 参数大于0
        ) {
          child.codegenNode.arguments.push(
            // 添加 PatchFlags.CACHED 标记到参数列表中
            PatchFlags.CACHED +
              (__DEV__ ? ` /* ${PatchFlagNames[PatchFlags.CACHED]} */` : ``),
          )
        }
        toCache.push(child) // 存储可缓存的节点
        continue
      }
    }

    // walk further
    // 递归处理元素节点
    // 表示元素节点,包括普通 HTML 元素和组件
    if (child.type === NodeTypes.ELEMENT) {
      const isComponent = child.tagType === ElementTypes.COMPONENT

      if (isComponent) {
        // 跟踪当前 v-slot 作用域的深度
        // 进入组件时增加计数
        context.scopes.vSlot++
      }
      walk(child, node, context, false, inFor)

      if (isComponent) {
        // 退出时减少计数
        context.scopes.vSlot--
      }

      // 递归处理 v-for 循环节点
    } else if (child.type === NodeTypes.FOR) {
      // Do not hoist v-for single child because it has to be a block
      walk(
        child,
        node,
        context,
        // 只有一个子节点,如果是则禁止提升
        // 原因:v-for 的单个子节点必须是一个块(block),因为 v-for 指令需要在 DOM 中创建和管理多个元素
        child.children.length === 1,
        true,
      )

      // 递归处理 v-if 条件判断节点
    } else if (child.type === NodeTypes.IF) {
      // 遍历 v-if 节点的所有分支,包括 if、else-if 和 else
      for (let i = 0; i < child.branches.length; i++) {
        // Do not hoist v-if single child because it has to be a block
        walk(
          child.branches[i],
          node,
          context,
          // 只有一个子节点,禁止提升
          // 原因:v-if 的单个子节点必须是一个块(block),因为 v-if 指令需要在 DOM 中创建和销毁元素
          child.branches[i].children.length === 1,
          inFor,
        )
      }
    }
  }

  let cachedAsArray = false // 缓存标识

  // 所有子节点都可缓存 并且 当前节点是元素节点
  if (toCache.length === children.length && node.type === NodeTypes.ELEMENT) {
    if (
      node.tagType === ElementTypes.ELEMENT && // 普通 HTML 元素
      node.codegenNode &&
      node.codegenNode.type === NodeTypes.VNODE_CALL && // 代码生成节点是虚拟节点调用
      isArray(node.codegenNode.children) // 节点是数组形式
    ) {
      // all children were hoisted - the entire children array is cacheable.
      // 对整个子节点数组进行缓存
      node.codegenNode.children = getCacheExpression(
        createArrayExpression(node.codegenNode.children),
      )
      cachedAsArray = true // 标记为已缓存
    } else if (
      node.tagType === ElementTypes.COMPONENT && // 组件
      node.codegenNode &&
      node.codegenNode.type === NodeTypes.VNODE_CALL && // 代码生成节点是虚拟节点调用
      node.codegenNode.children && // 子节点存在
      !isArray(node.codegenNode.children) && // 子节点不是数组形式
      // 子节点是 JavaScript 对象表达式
      node.codegenNode.children.type === NodeTypes.JS_OBJECT_EXPRESSION
    ) {
      // default slot
      // 获取名称为 'default' 的默认插槽
      const slot = getSlotNode(node.codegenNode, 'default')
      if (slot) {
        slot.returns = getCacheExpression(
          createArrayExpression(slot.returns as TemplateChildNode[]),
        )
        cachedAsArray = true // 标记已缓存
      }
    } else if (
      node.tagType === ElementTypes.TEMPLATE && // 模板
      parent &&
      parent.type === NodeTypes.ELEMENT && // 父节点是元素节点
      parent.tagType === ElementTypes.COMPONENT && // 父节点是组件
      parent.codegenNode &&
      parent.codegenNode.type === NodeTypes.VNODE_CALL && // 代码生成节点是虚拟节点调用
      parent.codegenNode.children && // 子节点存在
      !isArray(parent.codegenNode.children) && // 子节点不是数组形式
      // 子节点是 JavaScript 对象表达式
      parent.codegenNode.children.type === NodeTypes.JS_OBJECT_EXPRESSION
    ) {
      // named <template> slot
      // 获取名称为 'slot' 的插槽名称
      const slotName = findDir(node, 'slot', true)
      const slot =
        slotName &&
        slotName.arg &&
        getSlotNode(parent.codegenNode, slotName.arg)

      if (slot) {
        slot.returns = getCacheExpression(
          createArrayExpression(slot.returns as TemplateChildNode[]),
        )
        cachedAsArray = true // 标记已缓存
      }
    }
  }

  // 未标记缓存
  if (!cachedAsArray) {
    for (const child of toCache) {
      // 对每个子节点进行缓存
      child.codegenNode = context.cache(child.codegenNode!)
    }
  }

  /**
   * 缓存表达式
   * @param value 要缓存的表达式
   * @returns 缓存后的表达式
   */
  function getCacheExpression(value: JSChildNode): CacheExpression {
    // 创建缓存表达式,将传入的 value(通常是静态内容)进行缓存
    const exp = context.cache(value)
    // #6978, #7138, #7114
    // a cached children array inside v-for can caused HMR errors since
    // it might be mutated when mounting the first item
    // 问题:在 v-for 循环中使用缓存的子数组可能导致热模块替换(HMR)错误
    // 原因:当挂载第一个项目时,缓存的数组可能会被修改
    // 解决方法:通过数组展开避免直接修改原始缓存数组
    // #13221
    // fix memory leak in cached array:
    // cached vnodes get replaced by cloned ones during mountChildren,
    // which bind DOM elements. These DOM references persist after unmount,
    // preventing garbage collection. Array spread avoids mutating cached
    // array, preventing memory leaks.
    // 问题:缓存的 vnode 数组可能导致内存泄漏
    // 原因:
    // 1、在 mountChildren 期间,缓存的 vnode 会被克隆的 vnode 替换
    // 2、克隆的 vnode 会绑定 DOM 元素
    // 3、这些 DOM 引用在组件卸载后仍然存在,阻止垃圾回收
    // 解决方法:使用数组展开语法创建新数组,避免修改原始缓存数组,从而防止内存泄漏
    exp.needArraySpread = true // 设置数组展开标志
    return exp
  }

  /**
   * 获取插槽节点
   * @param node 生成代码节点
   * @param name 插槽名称
   * @returns 插槽节点
   */
  function getSlotNode(
    node: VNodeCall,
    name: string | ExpressionNode,
  ): SlotFunctionExpression | undefined {
    if (
      node.children && // 子节点存在
      !isArray(node.children) && // 子节点不是数组形式
      // 子节点是 JavaScript 对象表达式
      node.children.type === NodeTypes.JS_OBJECT_EXPRESSION
    ) {
      const slot = node.children.properties.find(
        // 属性的 key 直接等于 name
        // 属性的 key 是 SimpleExpressionNode 类型,且其 content 属性等于 name
        p => p.key === name || (p.key as SimpleExpressionNode).content === name,
      )
      // 返回其 value 属性(即插槽函数表达式)
      return slot && slot.value
    }
  }

  if (toCache.length && context.transformHoist) {
    // 静态提升
    context.transformHoist(children, context, node)
  }
}

PatchFlags 枚举

标志 (Flag) 数值 (Value) 含义说明
TEXT 1 元素文本内容是动态的(如 {{ msg }}
CLASS 1 << 1 = 2 元素的 class 绑定是动态的(如 :class="active"
STYLE 1 << 2 = 4 元素的 style 绑定是动态的(如 :style="{ color: red }"
PROPS 1 << 3 = 8 元素除 class/style 外,有其他动态属性(如 :id="userId"
FULL_PROPS 1 << 4 = 16 属性键(key)本身是动态的(如 :[propName]="value"),需要全量对比属性
HYDRATE_EVENTS 1 << 5 = 32 元素绑定了事件监听器(如 @click="handle"),主要用于服务端渲染后的“注水”(hydration)阶段
STABLE_FRAGMENT 1 << 6 = 64 片段(Fragment)的子节点顺序稳定,不会改变
KEYED_FRAGMENT 1 << 7 = 128 片段有带 key 的子节点,用于优化 v-for 列表渲染
UNKEYED_FRAGMENT 1 << 8 = 256 片段有无 key 的子节点,更新性能较差
NEED_PATCH 1 << 9 = 512 节点需要进行非属性(non-props)的补丁操作,如对 ref 或指令的处理
DYNAMIC_SLOTS 1 << 10 = 1024 组件含有动态插槽内容
DEV_ROOT_FRAGMENT 1 << 11 = 2048 仅在开发模式下,用于标记根片段
标志 (Flag) 数值 (Value) 含义说明
HOISTED -1 节点是静态的,已被提升,完全不需要参与 diff 对比
BAIL -2 渲染器应退出优化模式,进行完整的 diff 对比

预字符串化(Pre-stringification

Vue3 预字符串化(Pre-stringification) 是编译器在编译时针对大量连续静态节点的深度优化,将其直接合并为一个 HTML 字符串,大幅减少虚拟 DOM(VNode)数量与运行时开销。

触发条件

  • 节点数(NODE_COUNT):连续 ≥ 20 个纯静态节点
  • 带绑定元素数(ELEMENT_WITH_BINDING_COUNT):连续 ≥ 5 个含静态绑定(如 class="xxx")的元素

【示例】

<div>
    <p>段落1.</p>
    <p>段落2.</p>
    <p>段落3.</p>
    <p>段落4.</p>
    <p>段落5.</p>
    <p>段落6.</p>
    <p>段落7.</p>
    <p>段落8.</p>
    <p>段落9.</p>
    <p>段落10.</p>
    <p>段落11.</p>
</div>
import { createElementVNode as _createElementVNode, createStaticVNode as _createStaticVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [...(_cache[0] || (_cache[0] = [
    _createStaticVNode("<p>段落1.</p><p>段落2.</p><p>段落3.</p><p>段落4.</p><p>段落5.</p><p>段落6.</p><p>段落7.</p><p>段落8.</p><p>段落9.</p><p>段落10.</p><p>段落11.</p>", 11)
  ]))]))
}

源码 stringifyStatic

image.png

image.png

//  Vue3 预字符串化(Pre-stringification)
// 把连续的纯静态节点 → 直接编译成 HTML 字符串 → 运行时 innerHTML 插入,彻底跳过 VNode 创建、Diff、DOM 逐个生成流程
export const stringifyStatic: HoistTransform = (children, context, parent) => {
  // bail stringification for slot content
  // 插槽内容不做字符串化(插槽有作用域、动态性)
  if (context.scopes.vSlot > 0) {
    return
  }

  // 判断父节点是否已缓存
  const isParentCached =
    parent.type === NodeTypes.ELEMENT &&
    parent.codegenNode &&
    parent.codegenNode.type === NodeTypes.VNODE_CALL &&
    parent.codegenNode.children &&
    !isArray(parent.codegenNode.children) &&
    parent.codegenNode.children.type === NodeTypes.JS_CACHE_EXPRESSION

  let nc = 0 // current node count 当前连续静态节点总数量
  let ec = 0 // current element with binding count 当前带绑定的静态元素数量
  const currentChunk: StringifiableNode[] = [] // 待合并的静态节点队列

  // 执行合并
  const stringifyCurrentChunk = (currentIndex: number): number => {
    if (
      nc >= StringifyThresholds.NODE_COUNT || // 大于20
      ec >= StringifyThresholds.ELEMENT_WITH_BINDING_COUNT // 大于5
    ) {
      // combine all currently eligible nodes into a single static vnode call
      // 创建静态 VNode 调用
      const staticCall = createCallExpression(context.helper(CREATE_STATIC), [
        JSON.stringify(
          currentChunk.map(node => stringifyNode(node, context)).join(''),
        ).replace(expReplaceRE, `" + $1 + "`),
        // the 2nd argument indicates the number of DOM nodes this static vnode
        // will insert / hydrate
        String(currentChunk.length),
      ])

      const deleteCount = currentChunk.length - 1

      // 父节点已缓存:直接替换 children
      if (isParentCached) {
        // if the parent is cached, then `children` is also the value of the
        // CacheExpression. Just replace the corresponding range in the cached
        // list with staticCall.
        children.splice(
          currentIndex - currentChunk.length,
          currentChunk.length,
          // @ts-expect-error
          staticCall,
        )

        // 父节点未缓存:用第一个节点承载,删除剩下节点
      } else {
        // replace the first node's hoisted expression with the static vnode call
        ;(currentChunk[0].codegenNode as CacheExpression).value = staticCall
        if (currentChunk.length > 1) {
          // remove merged nodes from children
          children.splice(currentIndex - currentChunk.length + 1, deleteCount)
          // also adjust index for the remaining cache items
          const cacheIndex = context.cached.indexOf(
            currentChunk[currentChunk.length - 1]
              .codegenNode as CacheExpression,
          )
          if (cacheIndex > -1) {
            for (let i = cacheIndex; i < context.cached.length; i++) {
              const c = context.cached[i]
              if (c) c.index -= deleteCount
            }
            context.cached.splice(cacheIndex - deleteCount + 1, deleteCount)
          }
        }
      }
      return deleteCount
    }
    return 0
  }

  // 遍历子节点 → 收集连续静态节点 → 达到阈值就合并成 HTML 字符串 → 替换原节点
  let i = 0
  for (; i < children.length; i++) {
    const child = children[i]
    const isCached = isParentCached || getCachedNode(child)
    if (isCached) {
      // presence of cached means child must be a stringifiable node
      const result = analyzeNode(child as StringifiableNode)
      if (result) {
        // node is stringifiable, record state
        nc += result[0]
        ec += result[1]
        currentChunk.push(child as StringifiableNode)
        continue
      }
    }
    // we only reach here if we ran into a node that is not stringifiable
    // check if currently analyzed nodes meet criteria for stringification.
    // adjust iteration index
    i -= stringifyCurrentChunk(i)
    // reset state
    nc = 0
    ec = 0
    currentChunk.length = 0
  }
  // in case the last node was also stringifiable
  // 处理最后可能剩下的连续静态节点
  stringifyCurrentChunk(i)
}
function analyzeNode(node: StringifiableNode): [number, number] | false {
  // 非可字符串化标签直接返回 false
  if (node.type === NodeTypes.ELEMENT && isNonStringifiable(node.tag)) {
    return false
  }

  // v-once nodes should not be stringified
  //  如果节点有 v-once 指令,返回 false(v-once 节点不应被字符串化)
  if (node.type === NodeTypes.ELEMENT && findDir(node, 'once', true)) {
    return false
  }

  // 如果节点是文本调用节点,直接返回 [1, 0](1个节点,0个带绑定的元素)
  if (node.type === NodeTypes.TEXT_CALL) {
    // 第一个数字:节点总数
    // 第二个数字:带有绑定的元素数量
    return [1, 0]
  }

  let nc = 1 // node count 节点计数
  let ec = node.props.length > 0 ? 1 : 0 // element w/ binding count 带有绑定的元素计数
  let bailed = false // 标记是否放弃分析
  const bail = (): false => {
    bailed = true // 标记为放弃分析
    return false
  }

  // TODO: check for cases where using innerHTML will result in different
  // output compared to imperative node insertions.
  // probably only need to check for most common case
  // i.e. non-phrasing-content tags inside `<p>`
  // 分析元素节点是否可以安全地被字符串化
  function walk(node: ElementNode): boolean {
    // 特殊标签处理
    const isOptionTag = node.tag === 'option' && node.ns === Namespaces.HTML

    // 属性检查
    for (let i = 0; i < node.props.length; i++) {
      const p = node.props[i]
      // bail on non-attr bindings
      // 普通属性并且不可字符串化,调用 bail() 放弃分析
      if (
        p.type === NodeTypes.ATTRIBUTE &&
        !isStringifiableAttr(p.name, node.ns)
      ) {
        return bail()
      }
      if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') {
        // bail on non-attr bindings
        // 指令参数并且不可字符串化,调用 bail() 放弃分析
        if (
          p.arg &&
          (p.arg.type === NodeTypes.COMPOUND_EXPRESSION ||
            (p.arg.isStatic && !isStringifiableAttr(p.arg.content, node.ns)))
        ) {
          return bail()
        }

        // 指令表达式并且不可字符串化,调用 bail() 放弃分析
        if (
          p.exp &&
          (p.exp.type === NodeTypes.COMPOUND_EXPRESSION ||
            p.exp.constType < ConstantTypes.CAN_STRINGIFY)
        ) {
          return bail()
        }
        // <option :value="1"> cannot be safely stringified
        // 对于 <option> 标签的 :value 绑定,特殊处理(非静态表达式不可字符串化)
        if (
          isOptionTag &&
          isStaticArgOf(p.arg, 'value') &&
          p.exp &&
          !p.exp.isStatic
        ) {
          return bail()
        }
      }
    }
    // 子节点检查
    for (let i = 0; i < node.children.length; i++) {
      nc++
      const child = node.children[i]
      if (child.type === NodeTypes.ELEMENT) {
        if (child.props.length > 0) {
          ec++
        }
        // 递归检查子节点
        walk(child)
        if (bailed) {
          return false
        }
      }
    }
    return true
  }

  return walk(node) ? [nc, ec] : false
}

事件缓存

【示例】

  <div>
    <button @click="console.log('xxx')">click</button>
    <button @click="handleClick">点击</button>
    <button @click="() => {}">点击</button>
  </div>

未开启事件缓存 cacheHandlers 设置 false

import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = ["onClick"]
const _hoisted_2 = ["onClick"]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("button", {
      onClick: $event => (console.log('xxx'))
    }, "click", 8 /* PROPS */, _hoisted_1),
    _createElementVNode("button", { onClick: _ctx.handleClick }, "点击", 8 /* PROPS */, _hoisted_2),
    _cache[0] || (_cache[0] = _createElementVNode("button", { onClick: () => {} }, "点击", -1 /* CACHED */))
  ]))
}

开启事件缓存 cacheHandlers 设置 true

import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("button", {
      onClick: _cache[0] || (_cache[0] = $event => (console.log('xxx')))
    }, "click"),
    _createElementVNode("button", {
      onClick: _cache[1] || (_cache[1] = (...args) => (_ctx.handleClick && _ctx.handleClick(...args)))
    }, "点击"),
    _cache[2] || (_cache[2] = _createElementVNode("button", { onClick: () => {} }, "点击", -1 /* CACHED */))
  ]))
}

【示例】

  <div>
    <button @click="handleClick(message)">click</button>
    <button @click="handleClick('xx')">click</button>
  </div>
import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("button", {
      onClick: _cache[0] || (_cache[0] = $event => (_ctx.handleClick(_ctx.message)))
    }, "click"),
    _createElementVNode("button", {
      onClick: _cache[1] || (_cache[1] = $event => (_ctx.handleClick('xx')))
    }, "click")
  ]))
}

image.png

【示例】

  <div>
    <input v-model="message" placeholder="请输入信息" />
  </div>
import { vModelText as _vModelText, createElementVNode as _createElementVNode, withDirectives as _withDirectives, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _withDirectives(_createElementVNode("input", {
      "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((_ctx.message) = $event)),
      placeholder: "请输入信息"
    }, null, 512 /* NEED_PATCH */), [
      [_vModelText, _ctx.message]
    ])
  ]))
}

缓存的事件处理函数 $event => ((_ctx.message) = $event) 在组件整个生命周期中引用保持不变,但它却总能正确地将最新的输入值赋给响应式变量 _ctx.message

原因? 缓存的函数并没有“捕获” _ctx.message 的值,而是每次执行时动态地通过 _ctx 对象去访问 message 属性。而 _ctx 本身是一个组件实例的上下文代理对象,它在组件的整个生命周期中保持同一个引用,但其内部的属性(如 message)会随着响应式状态的变化而自动更新。

image.png

Block Tree

Block Tree 的核心理念是 将动态节点从静态节点中剥离出来,扁平化收集。它不是一个独立的运行时树形数据结构,而是编译器在模板编译阶段对 VNode 的一种标记和组织策略。

节点类型 原因说明
组件根节点 整个组件渲染的入口,天然形成一个 Block
带有 v-if / v-else / v-else-if 的节点 这些指令会导致节点的存在与否发生结构性变化,因此每个分支都会被包裹在一个独立的 Block 中
带有 v-for 的节点 列表渲染的节点结构可能会因数据变化而重排序或增删,所以会形成一个独立的 Block 来管理其动态子节点
多根节点模板(Fragment) 当模板有多个根节点时,这些根节点会被一个 Fragment 包裹,该 Fragment 节点也会成为一个 Block

【示例】

  <div>
    <p>段落</p>
    <p v-if="tag === 1">这里是header</p>
    <p v-else-if="tag === 2">这里是body</p>
    <p v-else>这里是footer</p>
  </div>
import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock, createCommentVNode as _createCommentVNode } from "vue"

const _hoisted_1 = { key: 0 }
const _hoisted_2 = { key: 1 }
const _hoisted_3 = { key: 2 }

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _cache[0] || (_cache[0] = _createElementVNode("p", null, "段落", -1 /* CACHED */)),
    (_ctx.tag === 1)
      ? (_openBlock(), _createElementBlock("p", _hoisted_1, "这里是header"))
      : (_ctx.tag === 2)
        ? (_openBlock(), _createElementBlock("p", _hoisted_2, "这里是body"))
        : (_openBlock(), _createElementBlock("p", _hoisted_3, "这里是footer"))
  ]))
}

【示例】

  <div>
    <ul>
      <li v-for="item in tag" :key="item">信息{{ item }} end</li>
    </ul>
  </div>
import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("ul", null, [
      (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.tag, (item) => {
        return (_openBlock(), _createElementBlock("li", { key: item }, "信息" + _toDisplayString(item) + " end", 1 /* TEXT */))
      }), 128 /* KEYED_FRAGMENT */))
    ])
  ]))
}

源码

render 函数执行:借助 openBlock 和 createBlock,将编译阶段标记出的动态节点,精准地收集到 dynamicChildren 数组中。

vue3-core/packages/runtime-core/src/vnode.ts

function setupBlock(vnode: VNode) {
  // save current block children on the block vnode
  // 保存动态子节点
  // 只有当 isBlockTreeEnabled > 0 时才启用跟踪
  vnode.dynamicChildren =
    isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null
  // close block
  closeBlock()
  // a block is always going to be patched, so track it as a child of its
  // parent block
  // 只有当 isBlockTreeEnabled > 0 时才启用跟踪
  if (isBlockTreeEnabled > 0 && currentBlock) {
    currentBlock.push(vnode)
  }
  return vnode
}
function createBlock(
  type: VNodeTypes | ClassComponent, // 虚拟节点的类型,可以是标签名、组件等
  props?: Record<string, any> | null, // 节点的属性对象
  children?: any, // 节点的子节点
  patchFlag?: number, // 补丁标志,用于优化更新过程
  dynamicProps?: string[], // 动态属性数组,指定哪些属性是动态的
): VNode {
  return setupBlock(
    // 创建一个基础虚拟节点
    createVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      // 表示这是一个 block 节点,用于跟踪动态子节点
      true /* isBlock: prevent a block from tracking itself */,
    ),
  )
}
function createElementBlock(
  type: string | typeof Fragment, // 字符串或 Fragment 符号,表示元素的标签名或片段
  props?: Record<string, any> | null, // 可选的属性对象,包含元素的属性、事件等
  children?: any, // 选的子节点,可以是字符串、数字、VNode 数组等
  patchFlag?: number, // 可选的补丁标志,用于优化更新过程
  dynamicProps?: string[], // 可选的动态属性数组,指定哪些属性是动态的
  shapeFlag?: number, // 可选的形状标志,表示 VNode 的类型
): VNode {
  // 将基础 VNode 转换为块节点
  return setupBlock(
    // 创建基础 VNode,设置各种属性和标志
    createBaseVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      shapeFlag,
      true /* isBlock */,
    ),
  )
}
❌