阅读视图

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

vxe-gantt table 甘特图如何设置任务视图每一行的背景色

vxe-gantt table 甘特图如何设置任务视图每一行的背景色,例如给不同任务设置不同背景色。

查看官网:gantt.vxeui.com/
gitbub:github.com/x-extends/v…
gitee:gitee.com/x-extends/v…

效果

image

代码

通过 task-view-config.viewStyle.cellStyle 设置任务视图行样式,也可以用 task-view-config.viewStyle.rowClassName 设置任务视图行附加 className

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive } from 'vue'
const ganttOptions = reactive({
  taskBarConfig: {
    showProgress: true,
    barStyle: {
      round: true,
      bgColor: '#fca60b',
      completedBgColor: '#65c16f'
    }
  },
  taskViewConfig: {
    viewStyle: {
      rowStyle ({ row }) {
        if (row.progress < 10) {
          return {
            backgroundColor: '#f1ccef'
          }
        }
        if (row.progress < 50) {
          return {
            backgroundColor: '#f8e4e4'
          }
        }
        return {}
      }
    }
  },
  columns: [
    { field: 'title', title: '任务名称' },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 }
  ],
  data: [
    { id: 10001, title: 'A项目', start: '2024-03-01', end: '2024-03-04', progress: 3 },
    { id: 10002, title: '城市道路修理进度', start: '2024-03-03', end: '2024-03-08', progress: 10 },
    { id: 10003, title: 'B大工程', start: '2024-03-03', end: '2024-03-11', progress: 90 },
    { id: 10004, title: '超级大工程', start: '2024-03-05', end: '2024-03-11', progress: 15 },
    { id: 10005, title: '地球净化项目', start: '2024-03-08', end: '2024-03-15', progress: 100 },
    { id: 10006, title: '一个小目标项目', start: '2024-03-10', end: '2024-03-21', progress: 5 },
    { id: 10007, title: '某某计划', start: '2024-03-15', end: '2024-03-24', progress: 70 },
    { id: 10008, title: '某某科技项目', start: '2024-03-20', end: '2024-03-29', progress: 50 },
    { id: 10009, title: '地铁建设工程', start: '2024-03-19', end: '2024-03-20', progress: 5 },
    { id: 10010, title: '铁路修建计划', start: '2024-03-12', end: '2024-03-20', progress: 10 }
  ]
})
</script>

gitee.com/x-extends/v…

2025 年最新 Fabric.js 实战:一个完整可上线的图片选区标注组件(含全部源码).

# 从 0 到 1 用最新 Fabric.js 实现:背景图 + 手动画矩形 + 坐标 + 删除 + 高清截图导出(2025 最新版)

最近项目要做一个「图片自动化清洗工具」,核心需求如下(产品甩给我的原话):

  1. 支持上传任意尺寸的广告图
  2. 运营要在图上框出需要保留的区域(可画多个矩形)
  3. 框出来的区域右上角必须有 × 可以删除
  4. 实时显示鼠标在原图上的精确坐标
  5. 最后要能把每个选区裁成独立图片 + 导出坐标数据给后端
  6. 必须有一键清空功能

我调研了 Konva、PixiJS、ZRender,最终还是选择了最成熟、最好用的 **Fabric.js**,并且使用最新版 v6.7.1+ 完美实现!

本文所有代码基于 Fabric.js 6.7+,完全适配 Vue3/React/原生JS,已在生产环境稳定运行。

一、Fabric.js 官网 & 安装方式(2025 最新)

二、bash

npm install fabric@latest

三、创建画布 & 初始化 CanvasManager 类

<template>
  <canvas ref="canvasEl" id="canvas"></canvas>
</template>
// CanvasManager.js
import { Canvas, FabricImage, Rect, Control } from "fabric";

export class CanvasManager {
  constructor(canvasEl, options = {}) {
    this.canvas = new Canvas(canvasEl, {
      width: canvasEl.clientWidth || 1000,
      height: canvasEl.clientHeight ||  || 700,
      backgroundColor: "#f5f5f5",
      selection: false,           // 全局禁止多选框(我们自己控制选中态)
      preserveObjectStacking: true,
    });

    // 回调函数,用于通知外部(如 Pinia/Vuex)矩形增删改
    this.onRectangleAdded   = options.onRectangleAdded;
    this.onRectangleUpdated  = options.onRectangleUpdated;
    this.onRectangleDeleted  = options.onRectangleDeleted;

    this.isDrawing = false;
    this.startX = this.startY = 0;
    this.currentRect = null;
    this.rectangles = [];         // 所有已完成的矩形
    this.originalImageWidth = this.originalImageHeight = 0;

    this.initEvents();
  }

  initEvents() {
    this.canvas.on("mouse:down", this.handleMouseDown.bind(this));
    this.canvas.on("mouse:move", this.handleMouseMove.bind(this));
    this.canvas.on("mouse:up",   this.handleMouseUp.bind(this));
  }
}

四、上传背景图 + 完美适配画布(支持 5000×5000 大图不卡)

FabricImage 设置canvas画布背景


<!-- 上传按钮(Ant Design Vue) -->
<input
  type="file"
  ref="fileInput"
  @change="handleFileUpload"
  accept="image/*"
  style="display: none"
/>
<a-button type="primary" @click="$refs.fileInput.click()">
  上传图片
</a-button>
      
     
// 设置背景图(核心方法)
async setBackground(source) {
  try {
    const img = await FabricImage.fromURL(source, { crossOrigin: "anonymous" });

    // 保存原始尺寸(后面导出坐标要用)
    this.originalImageWidth  = img.width;
    this.originalImageHeight = img.height;

    // 等比缩放至画布宽度(也可改成 scaleToHeight)
    img.scaleToWidth(this.canvas.getWidth());

    this.canvas.setBackgroundImage(img, () => {
      this.canvas.requestRenderAll();
    });

    return {
      originalSize: { width: img.width, height: img.height },
      scaledSize: { width: img.getScaledWidth(), height: img.getScaledHeight() },
      scaleX: img.scaleX,
      scaleY: img.scaleY,
    };
  } catch (err) {
    console.error("背景图加载失败", err);
    throw err;
  }
}

五、手动画矩形 + 右上角删除按钮(最丝滑写法)

在图片标注类工具中,“按住鼠标拖拽绘制矩形选区 + 右上角一键删除”是核心交互体验。本方案基于 Fabric.js v6.7+ 官方推荐的事件机制与自定义 Control 体系,实现了一套高可维护性、视觉统

绘制矩形区域
// 开始绘制矩形选区(鼠标按下时触发)
startDrawing(opt) {
  // 获取当前鼠标在画布上的精确坐标(已自动处理缩放、平移、滚动偏移)
  // getPointer 是 Fabric.js 官方推荐方法,比 e.layerX/e.offsetX 更准确
  const pointer = this.canvas.getPointer(opt.e);

  this.startX = pointer.x;// 记录矩形起始点的 X 坐标(左上角)
  this.startY = pointer.y;// 记录矩形起始点的 Y 坐标(左上角)

  // 标记当前处于“正在绘制”状态,后续 mouse:move 和 mouse:up 会用到
  this.isDrawing = true;

  // 创建一个新的 Fabric Rect 实例,作为当前正在绘制的矩形
  this.currentRect = new Rect({
    left: this.startX,// 矩形左上角 X 坐标(起始点)
    top: this.startY,// 矩形左上角 Y 坐标(起始点)
    width: 0, // 初始宽高为 0,后续拖拽时动态更新
    height: 0,
    fill: "rgba(255,0,0,0.3)", // 半透明红色填充,便于区分选区
    stroke: "red",// 边框颜色
    strokeWidth: 2,// 边框粗细
    selectable: true, // 允许被选中和拖拽移动
    hasControls: true,// 显示八个控制点(调整大小用)
    hasRotatingPoint: false,  // 禁止旋转手柄(我们不需要旋转)
    cornerColor: "red",  // 控制点角(调整大小时的八个小方块)颜色设为红色,与主题保持一致
    transparentCorners: false, // 控制点不透明(默认是半透明的,这里改成实心)
    cornerStyle: "circle",// 控制点形状为圆形(比默认矩形更好看)
    cornerSize: 12,// 控制点大小(像素)
    strokeDashArray: [5, 5],// 边框虚线样式 [实线长度, 间隔长度]
  });

  // 创建右上角的“× 删除”自定义控制点(自定义控制点是 Fabric.js 高阶用法)
  const deleteControl = this.createDeleteControl();

  // 把自定义删除按钮挂载到当前矩形的 controls 上,键名可以自定义
  // 之后可以通过 rect.setControlVisible('deleteBtn', false) 控制显隐
  this.currentRect.controls.deleteBtn = deleteControl;

  // 隐藏默认的旋转控制点(mtr = middle top rotate)
  // 我们已经禁用了旋转,这里再保险隐藏一下
  this.currentRect.setControlVisible("mtr", false);

  // 立即把矩形添加到画布(此时宽高为0,看不到,但必须先加进去才能实时更新)
  this.canvas.add(this.currentRect);

  // 触发重绘,确保即使宽高为0也能看到光标变化
  this.canvas.requestRenderAll();
},

一、体验丝滑的绘制能力。

核心交互流程严格遵循经典三阶段模型:

  1. mouse:down → 开启绘制模式

    1. 调用 canvas.getPointer(e) 获取相对于画布的精准坐标(自动补偿缩放、平移、视口滚动)
    2. 初始化 isDrawing = true 状态标志
    3. 创建临时 fabric.Rect 实例(初始宽高为 0)并立即加入画布,确保后续 move 事件可实时更新
  2. mouse:move → 实时更新矩形尺寸与位置

    1. 动态计算宽度/高度取绝对值,支持四个方向拖拽
    2. 动态调整 left/top 为较小值,保证矩形左上角始终为起始点
    3. 每帧调用 canvas.requestRenderAll() 实现流畅预览
  3. mouse:up → 绘制完成 & 防御性收尾

    1. 自动过滤误触(宽或高 < 10px 的矩形直接丢弃)
    2. 为有效矩形添加自定义删除控件(controls.deleteBtn)
    3. 将矩形实例推入管理数组,便于后续批量操作与数据同步
    4. 触发外部回调 onRectangleAdded,实现与 Pinia/Vuex/React 状态的完美解耦
鼠标事件
 // 鼠标按下事件
  handleMouseDown(opt) {
    if (opt.target) {
      return; // 点击了已有对象,进入编辑模式
    }
    if (this.isDrawing) return;

    this.startDrawing(opt);
  }
   // 鼠标移动事件
  handleMouseMove(opt) {
    if (!this.isDrawing || !this.currentRect) return;

    const pointer = this.canvas.getPointer(opt.e);
    const w = Math.abs(pointer.x - this.startX);
    const h = Math.abs(pointer.y - this.startY);

    this.currentRect.set({
      width: w,
      height: h,
      left: Math.min(pointer.x, this.startX),
      top: Math.min(pointer.y, this.startY),
    });

    this.canvas.requestRenderAll();
  }

  // 鼠标松开事件
  handleMouseUp() {
    if (!this.isDrawing || !this.currentRect) return;
    this.isDrawing = false;

    // 检查矩形尺寸,太小则删除
    if (this.currentRect.width < 5 || this.currentRect.height < 5) {
      this.canvas.remove(this.currentRect);
      this.currentRect = null;
      this.canvas.requestRenderAll();
      return;
    }

    this.finalizeRectangle();
  }
自定义删除控件(Custom Control)实现亮点
  • 位置锚点:x: 0.5, y: -0.5 + offsetX/Y 微调,精准定位在矩形右上角
  • 视觉风格:红色圆形底 + 白色粗体 ×,与 Ant Design/ProComponents 设计语言完全一致
  • 交互体验:cornerSize: 28 扩大点击区域,老年模式也能轻松点中
  • 性能优化:仅使用 Canvas 2D 绘制,无额外 DOM 元素,无内存泄漏
  • 事件隔离:mouseUpHandler 内部直接 canvas.remove(target) 并通知外部删除回调

Control就是可以设置图形的点。

createDeleteControl() {
  /**
   * 创建 Fabric.js 自定义删除控件
   * 该控件会出现在选中对象的右上角(可通过 x/y 调整位置)
   */
  return new Control({
    x: 0.5,                 // 水平锚点:0.5 表示对象右边缘
    y: -0.5,                // 垂直锚点:-0.5 表示对象上边缘
    offsetX: 10,            // 水平偏移量(向右偏移 10px)
    offsetY: -10,           // 垂直偏移量(向上偏移 10px)
    cursorStyle: "pointer", // 鼠标悬停时显示手型光标
    cornerSize: 24,         // 可点击区域大小(24×24px)
    
    // 自定义绘制删除图标(× 或垃圾桶图标)
    render: this.renderDeleteIcon.bind(this),
    
    // 点击删除按钮后执行的回调
    mouseUpHandler: this.deleteHandler.bind(this),
  });
}

简单的删除操作就是在画布中remove(对应的矩形) requestRenderAll重新渲染

六、清空所有选取

清空操作就是获取所有的矩形实例然后给remove ,重新渲染requestRenderAll。

七、完整代码

import { Canvas, FabricImage, Rect, Control } from "fabric";

export class CanvasManager {
  // 创建初始画布
  constructor(canvasEl, options = {}) {
    // 保存回调函数
    this.onRectangleAdded = options.onRectangleAdded;
    this.onRectangleUpdated = options.onRectangleUpdated;
    this.onRectangleDeleted = options.onRectangleDeleted;

    this.canvas = new Canvas(canvasEl, {
      width: canvasEl.clientWidth || 800,
      height: canvasEl.clientHeight || 600,
      backgroundColor: "#f0f0f0",
      selection: false,
    });

    this.isDrawing = false;
    this.startX = 0;
    this.startY = 0;
    this.currentRect = null;
    this.deleteIconImg = null;
    this.rectangles = [];

    this.initEvents();
  }

  // 设置背景图片
  async setBackground(imageUrl) {
    try {
      // 保存图片URL/路径
      this.imageUrl =
        typeof imageUrl === "string"
          ? imageUrl
          : imageUrl?.default || imageUrl?.src || imageUrl;

      const img = await FabricImage.fromURL(this.imageUrl);

      // 保存原始图片尺寸
      this.originalImageWidth = img.width;
      this.originalImageHeight = img.height;

      // 缩放图片以适应画布宽度
      img.scaleToWidth(this.canvas.getWidth());

      // 保存缩放后的尺寸(实际显示尺寸)
      this.scaledImageWidth = img.width * (img.scaleX || 1);
      this.scaledImageHeight = img.height * (img.scaleY || 1);

      this.canvas.backgroundImage = img;
      this.canvas.requestRenderAll();

      return {
        image: img,
        imageUrl: this.imageUrl, // 返回图片路径
        originalSize: {
          width: this.originalImageWidth,
          height: this.originalImageHeight,
        },
        scaledSize: {
          width: this.scaledImageWidth,
          height: this.scaledImageHeight,
        },
        scaleX: img.scaleX,
        scaleY: img.scaleY,
      };
    } catch (error) {
      console.error("背景加载失败:", error);
      throw error;
    }
  }

  // 获取图片URL/路径
  getImageUrl() {
    return this.imageUrl || null;
  }

  // 获取图片尺寸信息
  getImageSize() {
    if (!this.canvas.backgroundImage) {
      return null;
    }

    return {
      original: {
        width: this.originalImageWidth || 0,
        height: this.originalImageHeight || 0,
      },
      scaled: {
        width: this.scaledImageWidth || 0,
        height: this.scaledImageHeight || 0,
      },
      scaleX: this.canvas.backgroundImage.scaleX || 1,
      scaleY: this.canvas.backgroundImage.scaleY || 1,
    };
  }

  // 加载删除图标
  async loadDeleteIcon(iconUrl) {
    return new Promise((resolve) => {
      const img = new Image();
      img.src = iconUrl;
      img.onload = () => {
        this.deleteIconImg = img;
        resolve(img);
      };
      img.onerror = () => {
        console.warn("删除图标加载失败,使用默认样式");
        resolve(null);
      };
    });
  }

  // 初始化事件
  initEvents() {
    this.canvas.on("mouse:down", this.handleMouseDown.bind(this));
    this.canvas.on("mouse:move", this.handleMouseMove.bind(this));
    this.canvas.on("mouse:up", this.handleMouseUp.bind(this));
    this.canvas.on("selection:created", this.handleSelectionCreated.bind(this));
  }

  // 鼠标按下事件
  handleMouseDown(opt) {
    if (opt.target) {
      return; // 点击了已有对象,进入编辑模式
    }
    if (this.isDrawing) return;

    this.startDrawing(opt);
  }

  // 开始绘制
  startDrawing(opt) {
    const pointer = this.canvas.getPointer(opt.e);
    this.startX = pointer.x;
    this.startY = pointer.y;
    this.isDrawing = true;

    this.currentRect = new Rect({
      left: this.startX,
      top: this.startY,
      width: 0,
      height: 0,
      fill: "rgba(255,0,0,0.3)",
      stroke: "red",
      strokeWidth: 2,
      selectable: true,
      hasControls: true,
      hasRotatingPoint: false,
      cornerColor: "red",
      transparentCorners: false,
      cornerStyle: "circle",
      cornerSize: 12,
      strokeDashArray: [5, 5],
    });

    // 添加删除控制点
    const deleteControl = this.createDeleteControl();
    this.currentRect.controls.deleteBtn = deleteControl;
    this.currentRect.setControlVisible("mtr", false);

    this.canvas.add(this.currentRect);
    this.canvas.requestRenderAll();
  }

  // 创建删除控制点
  createDeleteControl() {
    return new Control({
      x: 0.5,
      y: -0.5,
      offsetX: 10,
      offsetY: -10,
      cursorStyle: "pointer",
      cornerSize: 24,
      render: this.renderDeleteIcon.bind(this),
      mouseUpHandler: this.deleteHandler.bind(this),
    });
  }

  // 鼠标移动事件
  handleMouseMove(opt) {
    if (!this.isDrawing || !this.currentRect) return;

    const pointer = this.canvas.getPointer(opt.e);
    const w = Math.abs(pointer.x - this.startX);
    const h = Math.abs(pointer.y - this.startY);

    this.currentRect.set({
      width: w,
      height: h,
      left: Math.min(pointer.x, this.startX),
      top: Math.min(pointer.y, this.startY),
    });

    this.canvas.requestRenderAll();
  }

  // 鼠标松开事件
  handleMouseUp() {
    if (!this.isDrawing || !this.currentRect) return;
    this.isDrawing = false;

    // 检查矩形尺寸,太小则删除
    if (this.currentRect.width < 5 || this.currentRect.height < 5) {
      this.canvas.remove(this.currentRect);
      this.currentRect = null;
      this.canvas.requestRenderAll();
      return;
    }

    this.finalizeRectangle();
  }

  // CanvasManager.js
  finalizeRectangle() {
    const id = `rect_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
    const coords = this.currentRect.getCoords();

    const rectData = {
      id,
      fabricObject: this.currentRect,
      coords: {
        tl: coords[0],
        tr: coords[1],
        br: coords[2],
        bl: coords[3],
      },
      left: this.currentRect.left,
      top: this.currentRect.top,
      width: this.currentRect.width,
      height: this.currentRect.height,
      angle: this.currentRect.angle,
    };

    // 保存到 rectangles 数组
    this.rectangles.push(rectData);

    // 直接调用回调函数添加到 store
    if (this.onRectangleAdded) {
      this.onRectangleAdded(rectData);
    }

    this.canvas.setActiveObject(this.currentRect);

    // 绑定实时更新事件
    this.currentRect.on("moving", () => this.updateRectCoords(rectData));
    this.currentRect.on("scaling", () => this.updateRectCoords(rectData));
    this.currentRect.on("rotating", () => this.updateRectCoords(rectData));

    this.currentRect = null;
    this.canvas.requestRenderAll();

    return rectData;
  }

  // 更新矩形坐标
  updateRectCoords(rectData) {
    const obj = rectData.fabricObject;
    const coords = obj.getCoords();

    rectData.coords = {
      tl: {
        x: Number(coords[0].x.toFixed(2)),
        y: Number(coords[0].y.toFixed(2)),
      },
      tr: {
        x: Number(coords[1].x.toFixed(2)),
        y: Number(coords[1].y.toFixed(2)),
      },
      br: {
        x: Number(coords[2].x.toFixed(2)),
        y: Number(coords[2].y.toFixed(2)),
      },
      bl: {
        x: Number(coords[3].x.toFixed(2)),
        y: Number(coords[3].y.toFixed(2)),
      },
    };

    rectData.left = Number(obj.left.toFixed(2));
    rectData.top = Number(obj.top.toFixed(2));
    rectData.width = Number(obj.width.toFixed(2));
    rectData.height = Number(obj.height.toFixed(2));
    rectData.angle = Number(obj.angle.toFixed(2));

    // 通知更新
    if (this.onRectangleUpdated) {
      this.onRectangleUpdated(rectData);
    }
  }

  // 选中事件
  handleSelectionCreated(opt) {
    const active = opt.target;
    const data = this.rectangles.find((r) => r.fabricObject === active);
    if (data) this.updateRectCoords(data);
  }

  // 删除处理
  deleteHandler(eventData, transform) {
    const target = transform.target;
    if (!target) return false;

    // 找到要删除的矩形数据
    const rectData = this.rectangles.find((r) => r.fabricObject === target);

    // 从 rectangles 中移除
    this.rectangles = this.rectangles.filter((r) => r.fabricObject !== target);

    // 调用删除回调
    if (rectData && this.onRectangleDeleted) {
      this.onRectangleDeleted(rectData.id);
    }

    this.canvas.remove(target);
    this.canvas.requestRenderAll();
    return true;
  }

  // 渲染删除图标
  renderDeleteIcon(ctx, left, top) {
    if (!this.deleteIconImg) {
      // 降级样式
      ctx.save();
      ctx.fillStyle = "red";
      ctx.beginPath();
      ctx.arc(left, top, 12, 0, Math.PI * 2);
      ctx.fill();
      ctx.strokeStyle = "white";
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(left - 6, top - 6);
      ctx.lineTo(left + 6, top + 6);
      ctx.moveTo(left + 6, top - 6);
      ctx.lineTo(left - 6, top + 6);
      ctx.stroke();
      ctx.restore();
      return;
    }

    const size = 20;
    ctx.drawImage(
      this.deleteIconImg,
      left - size / 2,
      top - size / 2,
      size,
      size
    );
  }

  // 获取所有矩形数据
  getRectangles() {
    return this.rectangles.map((rect) => ({
      id: rect.id,
      coords: rect.coords,
      left: rect.left,
      top: rect.top,
      width: rect.width,
      height: rect.height,
      angle: rect.angle,
    }));
  }

  // 清空画布(保留背景图片)
  clear() {
    // 获取所有对象(不包括背景图片)
    const objects = this.canvas.getObjects();

    // 移除所有对象
    objects.forEach((obj) => {
      this.canvas.remove(obj);
    });

    // 清空矩形数组
    this.rectangles = [];

    // 重新渲染
    this.canvas.requestRenderAll();
  }

  // 清空所有(包括背景图片)
  clearAll() {
    // 清空所有对象
    this.clear();

    // 清空背景图片
    this.canvas.backgroundImage = null;

    // 重新渲染
    this.canvas.requestRenderAll();
  }

  // 调整画布大小
  resize(width, height) {
    this.canvas.setDimensions({ width, height });
    if (this.canvas.backgroundImage) {
      this.canvas.backgroundImage.scaleToWidth(width);
    }
    this.canvas.requestRenderAll();
  }

  // 销毁
  destroy() {
    this.canvas.off("mouse:down");
    this.canvas.off("mouse:move");
    this.canvas.off("mouse:up");
    this.canvas.off("selection:created");
    this.canvas.dispose();
  }
}

还在为组件通信头疼?defineExpose让你彻底告别传值烦恼

最近在写Vue 3项目的时候,你是不是经常遇到这样的场景:父组件想要调用子组件里的方法,但在<script setup>里却不知道该怎么暴露出去?

每次都要翻文档查半天,最后可能还是用了不太优雅的解决方案。

别担心,今天我要给你介绍的defineExpose,就是专门解决这个痛点的神器。它能让你在<script setup>中轻松暴露组件方法,让组件通信变得前所未有的简单。

读完这篇文章,你不仅能掌握defineExpose的核心用法,还能学到几个实际项目中的最佳实践,从此再也不怕复杂的组件通信了!

为什么需要defineExpose?

在深入了解defineExpose之前,我们先来看看为什么会有这个API的出现。

在Vue 3的<script setup>语法糖出现之前,我们通常使用setup()函数来编写组件逻辑。在那个时候,如果要暴露方法给父组件,我们会这样做:

// 传统setup()函数写法
export default {
  setup() {
    const showMessage = () => {
      console.log('Hello from child component!')
    }
    
    // 需要手动返回
    return {
      showMessage
    }
  }
}

而在<script setup>中,默认情况下所有顶层的绑定(包括变量、函数)都是私有的,父组件无法直接访问。这就带来了一个问题:当父组件确实需要调用子组件的某些方法时,我们该怎么办?

这时候,defineExpose就闪亮登场了!

defineExpose基础用法

defineExpose是Vue 3专门为<script setup>设计的编译器宏,用来显式暴露组件实例上的属性和方法。

让我们从一个最简单的例子开始:

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

// 子组件内部的状态
const count = ref(0)
const message = '这是子组件的消息'

// 子组件内部的方法
const increment = () => {
  count.value++
  console.log('计数器增加了:', count.value)
}

const showAlert = () => {
  alert('这是子组件暴露的方法!')
}

// 使用defineExpose暴露需要让父组件访问的属性和方法
defineExpose({
  increment,
  showAlert,
  count
})
</script>

<template>
  <div>
    <p>子组件计数: {{ count }}</p>
  </div>
</template>

在父组件中,我们可以这样使用:

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

// 创建子组件的模板引用
const childRef = ref(null)

onMounted(() => {
  // 组件挂载后,可以通过childRef访问暴露的方法和属性
  console.log('子组件的count值:', childRef.value.count)
})

// 调用子组件暴露的方法
const handleButtonClick = () => {
  if (childRef.value) {
    childRef.value.increment()
    childRef.value.showAlert()
  }
}
</script>

<template>
  <div>
    <ChildComponent ref="childRef" />
    <button @click="handleButtonClick">调用子组件方法</button>
  </div>
</template>

看到这里,你可能已经明白了defineExpose的基本用法。它就像是在组件内部开了一个小窗口,让父组件能够看到和使用你特意暴露出来的功能。

defineExpose的高级技巧

掌握了基础用法后,让我们来看看一些在实际项目中特别有用的高级技巧。

选择性暴露

在实际开发中,我们通常不希望把所有内部方法和状态都暴露出去。defineExpose让我们可以精确控制暴露的内容:

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

// 内部状态 - 不暴露
const internalData = ref('这是内部数据,父组件看不到')

// 需要暴露的状态
const userInfo = reactive({
  name: '张三',
  age: 25
})

// 内部方法 - 不暴露
const internalMethod = () => {
  console.log('这是内部方法')
}

// 需要暴露的方法
const publicMethod = () => {
  console.log('这是对外公开的方法')
  internalMethod() // 内部方法可以在暴露的方法内部调用
}

const updateUserInfo = (newInfo) => {
  Object.assign(userInfo, newInfo)
}

// 只暴露必要的部分
defineExpose({
  publicMethod,
  updateUserInfo,
  userInfo
  // internalData 和 internalMethod 不会被暴露
})
</script>

组合式函数与defineExpose的结合

在大型项目中,我们经常使用组合式函数来组织逻辑。结合defineExpose,可以让代码更加清晰:

// useFormValidation.js - 表单验证的组合式函数
import { ref, computed } from 'vue'

export function useFormValidation() {
  const formData = ref({
    username: '',
    email: '',
    password: ''
  })

  const errors = ref({})

  // 计算属性 - 验证用户名
  const isUsernameValid = computed(() => {
    return formData.value.username.length >= 3
  })

  // 验证邮箱
  const validateEmail = () => {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    errors.value.email = emailRegex.test(formData.value.email) 
      ? '' 
      : '邮箱格式不正确'
  }

  // 整体验证
  const validateForm = () => {
    validateEmail()
    return Object.values(errors.value).every(error => !error)
  }

  // 重置表单
  const resetForm = () => {
    formData.value = { username: '', email: '', password: '' }
    errors.value = {}
  }

  return {
    formData,
    errors,
    validateForm,
    resetForm,
    isUsernameValid
  }
}

在组件中使用:

// FormComponent.vue
<script setup>
import { useFormValidation } from './useFormValidation'

const { 
  formData, 
  errors, 
  validateForm, 
  resetForm,
  isUsernameValid 
} = useFormValidation()

// 提交表单的方法
const submitForm = () => {
  if (validateForm()) {
    console.log('表单验证通过,准备提交:', formData)
    // 这里可以添加提交逻辑
  }
}

// 只暴露父组件需要的方法
defineExpose({
  validateForm,
  resetForm,
  submitForm
})
</script>

<template>
  <form>
    <input v-model="formData.username" placeholder="用户名" />
    <span v-if="!isUsernameValid">用户名至少3个字符</span>
    
    <input v-model="formData.email" placeholder="邮箱" />
    <span>{{ errors.email }}</span>
    
    <button type="button" @click="submitForm">提交</button>
  </form>
</template>

实际项目中的最佳实践

在真实项目开发中,正确使用defineExpose能让你的代码更加健壮和可维护。

类型安全的defineExpose

如果你使用TypeScript,可以为暴露的内容添加类型定义:

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

const increment = () => {
  count.value++
}

const resetCount = (value: number = 0) => {
  count.value = value
}

// 定义暴露接口的类型
interface ExposedProps {
  increment: () => void
  resetCount: (value?: number) => void
  count: number
}

// 类型安全的暴露
defineExpose<ExposedProps>({
  increment,
  resetCount,
  count
})
</script>

表单组件的完整示例

让我们看一个更完整的表单组件示例,这在管理后台系统中非常常见:

// AdvancedForm.vue
<script setup>
import { ref, reactive, computed, watch } from 'vue'

// 表单数据
const formModel = reactive({
  title: '',
  content: '',
  category: '',
  tags: [],
  publishTime: ''
})

// 表单验证状态
const validationState = reactive({
  isTitleValid: false,
  isContentValid: false,
  isCategoryValid: false
})

// 计算属性 - 表单是否完整
const isFormComplete = computed(() => {
  return Object.values(validationState).every(valid => valid)
})

// 监听表单变化
watch(() => formModel.title, (newTitle) => {
  validationState.isTitleValid = newTitle.length >= 5
})

watch(() => formModel.content, (newContent) => {
  validationState.isContentValid = newContent.length >= 10
})

watch(() => formModel.category, (newCategory) => {
  validationState.isCategoryValid = !!newCategory
})

// 提交方法
const submit = async () => {
  if (!isFormComplete.value) {
    throw new Error('表单未填写完整')
  }
  
  // 模拟API调用
  console.log('提交数据:', formModel)
  return { success: true, message: '提交成功' }
}

// 重置方法
const reset = () => {
  Object.assign(formModel, {
    title: '',
    content: '',
    category: '',
    tags: [],
    publishTime: ''
  })
  Object.keys(validationState).forEach(key => {
    validationState[key] = false
  })
}

// 获取表单数据
const getFormData = () => {
  return { ...formModel }
}

// 设置表单数据
const setFormData = (newData) => {
  Object.assign(formModel, newData)
}

// 暴露给父组件的方法和属性
defineExpose({
  submit,
  reset,
  getFormData,
  setFormData,
  isFormComplete
})
</script>

<template>
  <div class="advanced-form">
    <input v-model="formModel.title" placeholder="文章标题" />
    <textarea v-model="formModel.content" placeholder="文章内容"></textarea>
    <select v-model="formModel.category">
      <option value="">选择分类</option>
      <option value="tech">技术</option>
      <option value="life">生活</option>
    </select>
  </div>
</template>

父组件使用示例:

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

const formRef = ref(null)

// 保存草稿
const saveDraft = async () => {
  try {
    const result = await formRef.value.submit()
    console.log('保存成功:', result)
  } catch (error) {
    console.error('保存失败:', error.message)
  }
}

// 重置表单
const clearForm = () => {
  formRef.value.reset()
}

// 从服务器加载数据到表单
const loadFormData = () => {
  const mockData = {
    title: 'Vue 3高级技巧',
    content: '这是一篇关于Vue 3的文章...',
    category: 'tech',
    tags: ['vue', 'javascript'],
    publishTime: '2024-01-20'
  }
  formRef.value.setFormData(mockData)
}
</script>

<template>
  <div>
    <AdvancedForm ref="formRef" />
    <button @click="saveDraft">保存草稿</button>
    <button @click="clearForm">清空表单</button>
    <button @click="loadFormData">加载数据</button>
  </div>
</template>

常见问题与解决方案

在实际使用defineExpose时,你可能会遇到一些典型问题,这里我为你整理了解决方案。

问题1:模板引用为null

这是最常见的问题之一,通常是因为在组件挂载完成前就尝试访问引用。

// ❌ 错误用法
const childRef = ref(null)
console.log(childRef.value) // 输出: null

// ✅ 正确用法
const childRef = ref(null)

onMounted(() => {
  console.log(childRef.value) // 输出: 组件实例
})

// 或者在事件处理程序中访问
const handleClick = () => {
  if (childRef.value) {
    childRef.value.someMethod()
  }
}

问题2:方法未定义

如果调用方法时出现"undefined"错误,检查是否正确定义和暴露了该方法。

// ❌ 忘记暴露方法
<script setup>
const myMethod = () => {
  console.log('hello')
}
// 忘记调用 defineExpose
</script>

// ✅ 正确暴露
<script setup>
const myMethod = () => {
  console.log('hello')
}

defineExpose({
  myMethod
})
</script>

问题3:响应式数据更新问题

当父组件修改子组件暴露的响应式数据时,需要注意:

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

const count = ref(0)

// 提供安全的方法来修改数据
const safeIncrement = () => {
  count.value++
}

const safeSetCount = (newValue) => {
  if (typeof newValue === 'number') {
    count.value = newValue
  }
}

defineExpose({
  count,
  safeIncrement,
  safeSetCount
  // 不直接暴露count的.value属性,而是通过方法控制
})
</script>

总结

通过今天的学习,相信你已经对Vue 3的defineExpose有了全面的了解。

defineExpose<script setup>中的编译器宏,专门用于暴露组件方法和属性给父组件。它的核心价值在于:

第一,提供了精确的控制能力,让你能够决定哪些内容对外可见,保持组件的封装性。

第二,与组合式函数完美配合,让复杂的组件逻辑能够清晰地组织和暴露。

第三,在TypeScript项目中提供完整的类型安全支持。

最重要的是,它解决了<script setup>中组件通信的关键痛点,让父组件能够以类型安全的方式调用子组件的功能。

TinyEngine 低代码实时协作揭秘:原理 +实操,看完直接用!

本文由周天意同学原创。

一般的多人协作业务需求一般是针对文档,表格或者是制图之类的,场景比较简单,协同操作的对象为文字或者图片,对象比较单一。 乍一看低代码的多人协作看似无从下手,因为低代码不仅涉及到页面 canvas 中一些文字属性的同步,还涉及到组件拖拽,样式,绑定事件,高级属性,甚至是代码协同编辑的编辑与同步。那我们是如何在低代码这个场景下实现多人协同编辑的呢。

TinyEngine低代码引擎多人协同技术详解

CRDT

我们首先来介绍一下实现低代码编辑的协同编辑的底层逻辑 —— CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)是一种允许并发修改、自动合并且永不冲突的数据结构。 即使多个用户同时编辑同一份文档、表格或图形,系统也能在之后自动合并出一致的结果,不需要“锁”或“人工解决冲突”

一个例子

假设你有一个协作文本编辑器有两个用户: A插入“Hello ” B插入“World!”

在普通系统中,如果两个操作几乎同时发生,可能导致冲突(比如:谁的改动算数?)。但在 CRDT 模型下,每个操作都是可合并的:系统会基于操作的逻辑时间或唯一标识符自动确定合并顺序;最终所有节点都会收敛到相同的状态,比如 "Hello World!"。

CRDT 的两种主要类型

  1. State-based(状态型 CRDT) 每个节点维护完整的状态副本,并定期将状态合并: local_state = merge(local_state, remote_state)

  2. Operation-based(操作型 CRDT) 每个节点只传播“操作”,比如“加1”“插入字符X”, 其他节点按相同逻辑执行该操作。

在我们的项目中,我们采用的是 操作型 CRDT(Operation-based CRDT)库 Yjs。 在 Yjs 中,每个协同文档对应一个根对象 Y.Doc,它可以包含多种可协同的数据结构,例如 Y.Array、Y.Map、Y.Text 等。每个客户端都维护一份本地的 Y.Doc 副本,这些副本通过 Yjs 的同步机制保持一致。 当多个客户端通过 y-websocket provider 连接到同一个房间(room)时,它们会共享相同的文档数据。任何客户端对文档的修改(如插入、删除、更新)都会被编码为操作(operation),并广播到其他客户端,从而实现实时的数据同步。

从数据结构到协同模型:tiny-engine 的页面 Schema 与 Yjs 的结合

通过前面的讨论我们可以发现,无论是哪一种类型的 CRDT(Conflict-free Replicated Data Type),其核心都离不开一个健全且完备的数据结构。 对于我们的 tiny-engine 来说,低代码页面本身也是由一套结构化的数据所描述的。 这套数据结构不仅要支持页面的层级关系(如区块、组件、插槽),还要能够表达页面的动态逻辑(如循环、条件、生命周期、数据源等)。

在 tiny-engine 中,页面的基础结构可以抽象为以下两个 TypeScript 接口:

// 节点类型
export interface Node {
  id: string
  componentName: string
  props: Record<string, any> & { columns?: { slots?: Record<string, any> }[] }
  children?: Node[]
  componentType?: 'Block' | 'PageStart' | 'PageSection'
  slot?: string | Record<string, any>
  params?: string[]
  loop?: Record<string, any>
  loopArgs?: string[]
  condition?: boolean | Record<string, any>
}
  
// 根节点类型,即页面 Schema
export type RootNode = Omit<Node, 'id'> & {
  id?: string
  css?: string
  fileName?: string
  methods?: Record<string, any>
  state?: Record<string, any>
  lifeCycles?: Record<string, any>
  dataSource?: any
  bridge?: any
  inputs?: any[]
  outputs?: any[]
  schema?: any
}

我们可以把它理解为:

  • Node 代表页面中的一个通用组件节点;
  • RootNode 则是整个页面的根节点(Schema),在 Node 的基础上扩展了页面级的属性,如 statemethodslifeCycles 等。

从数据结构到协同对象

在使用 CRDT(这里是 Yjs 进行实时协作时,我们的“协作单元”就是上述的这类数据结构。换句话说,Yjs 需要在内部维护一份与 RootNode 对应的共享状态副本。

然而,Yjs 并不能直接理解复杂的 TypeScript 对象结构,我们需要将其转化为 Yjs 能够识别和同步的类型系统。 例如:

  • 普通对象 → Y.Map
  • 数组 → Y.Array
  • 字符串、数字、布尔值 → Y.Text / 基本类型
  • 嵌套结构(如 children)则需要递归地转化为嵌套的 Y 类型。

因此,我们的第一步工作是:

根据已有的 NodeRootNode 数据结构,将其映射为等价的 Yjs 类型(如 Y.Map、Y.Array 等)。

这一过程可以抽象为一个通用的 “schema → YDoc” 转换函数。项目中:

const UNDEFINED_PLACEHOLDER = '__undefined__'
  
/**
 * 将普通对象/数组递归转换成 Yjs 对象
 * @param target Y.Map 或 Y.Array
 * @param obj 要转换的对象
 */
// toYjs 函数优化后的版本
  
export function toYjs(target: Y.Map<any> | Y.Array<any>, obj: any) {
  if (Array.isArray(obj)) {
    if (!(target instanceof Y.Array)) {
      throw new Error('Expected Y.Array as target for array input')
    }
    obj.forEach((item) => {
      if (item === undefined) {
        target.push([UNDEFINED_PLACEHOLDER])
      } else if (item === null) {
        target.push([null])
      } else if (Array.isArray(item)) {
        const childArr = new Y.Array()
        toYjs(childArr, item)
        target.push([childArr])
      } else if (typeof item === 'object' && item !== null) {
        // 明确排除 null
        const childMap = new Y.Map()
        toYjs(childMap, item)
        target.push([childMap])
      } else {
        target.push([item])
      }
    })
  } else if (typeof obj === 'object' && obj !== null) {
    if (!(target instanceof Y.Map)) {
      throw new Error('Expected Y.Map as target for object input')
    }
    Object.entries(obj).forEach(([key, val]) => {
      if (val === undefined) {
        target.set(key, UNDEFINED_PLACEHOLDER)
      } else if (val === null) {
        target.set(key, null)
      } else if (Array.isArray(val)) {
        const yArr = new Y.Array()
        target.set(key, yArr)
        toYjs(yArr, val)
      } else if (typeof val === 'object' && val !== null) {
        // 明确排除 null
        const yMap = new Y.Map()
        target.set(key, yMap)
        toYjs(yMap, val)
      } else {
        target.set(key, val)
      }
    })
  }
  // 注意:如果 obj 不是对象或数组(如 string, number),函数将静默地不做任何事。这是符合预期的。
}
  
// 将 Yjs Map 转回普通对象(递归)
export function fromYjs(value: any): any {
  if (value instanceof Y.Map) {
    const obj: any = {}
    value.forEach((v, k) => {
      obj[k] = fromYjs(v)
    })
    return obj
  } else if (value instanceof Y.Array) {
    return value.toArray().map((item) => fromYjs(item))
  } else if (value instanceof Y.Text) {
    return value.toString()
  } else if (value === UNDEFINED_PLACEHOLDER) {
    return undefined // 还原 undefined
  } else {
    return value
  }
}

这样,当我们通过 Yjs 对这些 Y 类型进行修改(例如修改 props、插入/删除 children、更新 state),Yjs 就会自动维护 CRDT 冲突合并逻辑,并将变更同步到所有协作客户端。

监听机制实现 —— 从 Yjs 变更到多人协同视图更新

前面的步骤成功让我们借助 Yjs 实现了数据层面的实时同步: 无论是哪位协作者修改了页面中的某个节点、属性或层级结构,这些变更都能被同步传播到所有客户端。

但是,仅仅让数据“同步”还不够。 在 tiny-engine 中,页面渲染与编辑的核心状态仍然依赖于本地的 Schema(即 RootNodeNode 的结构树)。 换句话说:

Yjs 负责维护协作的共享状态,但页面的实际渲染与交互仍是基于本地内存中的 Schema。

因此,我们必须建立一套监听机制,让 Yjs 的变更能够驱动 Schema 与视图的更新,形成如下的完整同步链路:

Yjs 数据变化 → 更新本地 Schema → 触发渲染引擎刷新视图

非常好 👍,你这里实际上引出了多人协同中最关键的一个设计点——“操作意图层”和“数据层”的解耦”。 你的思路已经非常正确:用事件总线处理结构性变更(如节点插入/删除),用 meta 元数据追踪属性变更。下面我帮你把这一节内容完整、系统地扩写成技术博客风格,同时保留你的原始语义与工程感。👇

实现思路:Yjs observe 机制

Yjs 为我们提供了非常强大的变更监听机制:

  • observe:监听单个 Y.MapY.Array 的变更;
  • observeDeep:递归监听整个文档中的所有嵌套结构(常用于复杂 Schema)。

通过这些监听器,我们可以捕获到所有节点层面的增删改事件(包括 props、children 等),然后将这些变化同步回本地 Schema

问题:结构性操作缺乏语义信息

在理论上,observe 能告诉我们「有节点被插入」,但在实际业务逻辑中,这个信息远远不够。

以节点插入为例,tiny-engine 中的插入函数如下所示:

const insertAfter = ({ parent, node, data }: InsertOptions) => {
  if (!data.id) {
    data.id = utils.guid()
  }
  
  useCanvas().operateNode({
    type: 'insert',
    parentId: parent.id || '',
    newNodeData: data,
    position: 'after',
    referTargetNodeId: node.id
  })
}

可以看到,插入一个节点不仅仅是向 children 数组中多 push 一个元素,而是依赖一系列上下文信息:

  • 插入到哪个父节点(parentId);
  • 相对哪个参考节点(referTargetNodeId);
  • 插入位置(position:before/after/append 等);

但是在 Yjs 的底层结构中,这些上下文信息在同步时都会丢失。 我们只会收到一条 “children 数组新增了一个元素” 的事件:

event.changes.added // => [Y.Map({ id: 'new-node-id', ... })]

这时我们无法推断出节点是“如何插入”的,也就无法还原编辑器层面的真实操作。 换句话说,Yjs 提供了数据变化的结果,但我们需要的是操作的意图

解决方案:事件总线 + meta 元数据

为了解决这一问题,我们在架构中引入了两个关键机制:

机制 主要负责 作用范围
事件总线(Event Bus) 传播节点级操作的语义,如新增、删除、移动等 结构性操作
Meta 元数据(Metadata) 描述节点属性、状态等细粒度变化 属性级操作

1. 事件总线:同步操作意图

事件总线的设计目标是让每一个“可复现的操作”都能以事件的形式传播到协作层中。

我们会在 Yjs 文档中专门创建一个 __app_events__ 通道,用于通信:

// 创建事件通道
const eventsMap = this.yDoc.getMap('__app_events__')
  
// 开启事务保证原子性
this.yDoc.transact(() => {
  // 在目标节点上设置软删除标志,防止幽灵事件
  targetNode.set('_node_deleted', true)
  
  // 获取事件总线
  const eventsMap = this.yDoc.getMap('__app_events__')
  
  // 准备事件负载
  const eventPayload = {
    op: 'delete',
    deletedNodeId: id,
    // TODO: 可以在负载中包含被删除前的数据,便于远程客户端做一些高级处理(如 "恢复" 功能)
    previousNodeData,
    timestamp: Date.now()
  }
  
  // 使用唯一 ID 发布事件
  const eventId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
  eventsMap.set(eventId, eventPayload)
}, 'local-delete-operation')

监听器设计

// 设置一个专门的监听器来处理来自“事件总线”的自定义操作
// 处理无法被 initObserver 监听器很好处理的事件
public setupEventListeners(docName: string): void {
  // 解绑旧的监听器,防止重复
  if (this.eventListeners.has(docName)) {
    const { map, cb } = this.eventListeners.get(docName)
    map.unobserve(cb)
  }
  
  const docManager = DocManager.getInstance()
  const ydoc = docManager.getOrCreateDoc(docName)
  const eventsMap = ydoc.getMap('__app_events__')
  
  const eventCallback = (event: Y.YMapEvent<any>, transaction: Y.Transaction) => {
    if (transaction.local) return
  
    event.changes.keys.forEach((change, key) => {
      if (change.action === 'add') {
        const payload: any = eventsMap.get(key)
  
        if (payload && payload.op === 'move') {
          const patch: DiffPatch = {
            type: 'array-swap',
            parentId: payload.parentId,
            schemaId: payload.schemaId,
            swapId: payload.swapId
          }
          this.applyPatches(docName, [patch])
        } else if (payload && payload.op === 'insert') {
          const patch: DiffPatch = {
            type: 'array-insert',
            parentId: payload.parentId,
            newNodeData: payload.newNodeData,
            position: payload.position,
            referTargetNodeId: payload.referTargetNodeId
          }
  
          this.applyPatches(docName, [patch])
        } else if (payload && payload.op === 'delete') {
          const patch: DiffPatch = {
            type: 'array-delete',
            deletedId: payload.deletedNodeId,
            previousNodeData: payload.previousNodeData
          }
  
          this.applyPatches(docName, [patch])
        }
      }
  
      eventsMap.delete(key)
    })
  }
  
  // 绑定监听器
  eventsMap.observe(eventCallback)
  this.eventListeners.set(docName, { map: eventsMap, cb: eventCallback })
}

这样,每当一个用户在本地执行节点插入或删除操作时:

a. 编辑器会向事件总线发送一条“操作意图”; b. 该事件会被同步到 Yjs 的 __app_events__; c. 所有协作者客户端的监听器收到事件后,调用 operateNode 重放操作; d. 从而保持逻辑一致性与结构同步。

这种做法本质上是 “Yjs 同步结果 + EventBus 同步语义” 的结合。

2. Meta 元数据:追踪节点属性变化

而对于节点属性(如 propsstyleloopcondition 等)而言,我们并不需要同步操作意图,只需同步最终结果即可。 因此我们在每个节点的 Yjs 表示中增加一份 meta 元数据

const yNode = new Y.Map()
yNode.set('meta', new Y.Map({
  lastModifiedBy: userId,
  lastModifiedAt: Date.now(),
  changeType: 'props'
}))

当属性发生修改时,我们更新对应的 meta 字段,这样协作者就能知道:

  • 是哪个用户修改的;
  • 修改了什么部分;
  • 修改时间等信息。

并通过 observeDeep 自动捕获变化,实现属性级别的实时同步。

这种模式下,结构操作(增删节点)和属性操作(节点内部更新)各司其职,不会互相干扰。

架构小结

通过事件总线与 meta 元数据的结合,我们实现了 Yjs 协同编辑的完整闭环:

用户操作 → 发布事件(EventBus)
          ↓
     同步到 Yjs (__app_events__)
          ↓
     其他客户端接收 → 重放操作
          ↓
     Schema & 视图更新

而对于属性更新的路径:

用户编辑属性 → 更新节点 meta + props
          ↓
     Yjs observeDeep 监听到变化
          ↓
     同步到其他客户端 → 更新本地 Schema
          ↓
     触发视图重绘

这种分层架构既保持了 Yjs 的一致性特性,又补上了协同编辑中至关重要的 操作语义层,让多人实时协同真正具备“人理解的上下文逻辑”。

非常好,这一节正是整个 反向同步链路(Schema → Yjs) 的核心部分。下面是经过润色和扩展后的完整博客内容片段,可以直接用于技术文档或博客文章中👇

反向同步机制 —— 从 Schema 改动更新 Yjs

在前面我们已经介绍了如何通过 Yjs 的变更来驱动本地 Schema 的更新,实现了**“远端 → 本地”** 的同步逻辑。 而这一节要讲的,则是反向过程:当本地用户操作导致 Schema 发生变化时,如何将这些变更同步到 Yjs 文档,从而广播给其他协作者。

基本思路

反向同步的核心理念是:

当本地 Vue 响应式状态(Schema)发生变化时,我们通过 Vue Hook 捕获到变更,并将这些变更同步到 Yjs 的共享结构中。

这一机制的关键在于对 操作意图(Operation Intent) 的捕获,而不是单纯地对数据差异做比对。 也就是说,我们并不是在检测“数据变了多少”,而是在监听“用户执行了什么操作”——比如插入节点、删除节点、修改属性等。

添加节点的示例

以“添加节点”为例,当用户在编辑器中执行插入操作时,实际的 Schema 改动会通过以下函数完成:

export const insertNode = (
  node: { node: Node; parent: Node; data: Node },
  position: PositionType = POSITION.IN,
  select = true
) => {
  if (!node.parent) {
    insertInner({ node: useCanvas().pageState.pageSchema!, data: node.data }, position)
  } else {
    switch (position) {
      case POSITION.TOP:
      case POSITION.LEFT:
        insertBefore(node)
        break
      case POSITION.BOTTOM:
      case POSITION.RIGHT:
        insertAfter(node)
        break
      case POSITION.IN:
        insertInner(node)
        break
      case POSITION.OUT:
        insertContainer(node)
        break
      case POSITION.REPLACE:
        insertReplace(node)
        break
      default:
        insertInner(node)
        break
    }
  }
  
  if (select) {
    setTimeout(() => selectNode(node.data.id))
  }
  
  getController().addHistory()
}

我们重点关注 insertBefore 函数的实现:

const insertBefore = ({ parent, node, data }: InsertOptions) => {
  if (!data.id) {
    data.id = utils.guid()
  }
  
  // 更新本地 Schema
  useCanvas().operateNode({
    type: 'insert',
    parentId: parent.id || '',
    newNodeData: data,
    position: 'before',
    referTargetNodeId: node.id
  })
  
  // 多人协作同步
  useRealtimeCollab().insertSharedNode({ node, parent, data }, POSITION.TOP)
}

可以看到,当本地 Schema 执行节点插入后,接下来就通过 useRealtimeCollab().insertSharedNode(...) 来完成与 Yjs 的同步。

核心逻辑:insertSharedNode

insertSharedNode 是整个反向同步机制的关键函数,它的主要职责是:

  1. 确定 Yjs 结构中目标位置 通过 parent.id 获取共享文档中对应的 Y.MapY.Array,找到应插入的目标节点。

  2. 构造 Yjs 节点对象 将本地的 Node 数据结构序列化为对应的 Yjs 类型(Y.Map),并递归地将 propschildren 等字段映射为 Yjs 可操作的数据结构。

  3. 执行事务性插入 使用 ydoc.transact() 进行原子操作,保证一次插入在所有协作者中状态一致。

下面是一个简化后的核心示例逻辑:

// 拖拽行为产生的节点插入
public insertNode({ node, parent, data }: InsertOptions, position: PositionType) {
  let insertPos
  let insertPosFinal
  
  if (!parent) {
    this.insert(useCanvas().pageState.pageSchema!.id as string, data, position)
  } else {
    switch (position) {
      case POSITION.TOP:
      case POSITION.LEFT:
        this.insert(parent.id || '', data, 'before', node.id)
        break
      case POSITION.BOTTOM:
      case POSITION.RIGHT:
        this.insert(parent.id || '', data, 'after', node.id)
        break
      case POSITION.IN:
        insertPos = ([POSITION.TOP, POSITION.LEFT] as string[]).includes(position) ? 'before' : 'after'
        this.insert(node.id || '', data, insertPos)
        break
      case POSITION.OUT:
        this.insert(parent.id || '', data, POSITION.OUT, node.id)
        break
      case POSITION.REPLACE:
        this.insert(parent.id || '', data, 'replace', node.id)
        break
      default:
        insertPosFinal = ([POSITION.TOP, POSITION.LEFT] as string[]).includes(position) ? 'before' : 'after'
        this.insert(node.id || '', data, insertPosFinal)
        break
    }
  }
}
  
// insert 操作
private insert(parentId: string, newNodeData: Node, position: string, referTargetNodeId?: string) {
  this.operationHandler.insert({
    type: 'insert',
    parentId,
    newNodeData,
    position,
    referTargetNodeId
  })
}

其实就相当于重写了 insertNode 来实现 Yjs 的变动

Vue Hook 的作用

在实际工程中,我们通常会将这类同步逻辑封装在一个组合式 Hook 中,比如:

/**
 * useCollabSchema Composable
 * 职责:
 * 1. 整合 Y.Doc (持久化数据) 和 Y.Awareness (瞬时状态) 的同步。
 * 2. 提供对共享文档结构 (Schema) 的增删改 API。
 * 3. 提供对远程用户实时状态的响应式数据和更新 API。
 */
export function useCollabSchema(options: UseCollabSchemaOptions) {
  const { roomId, currentUser } = options
  const { awareness, provider } = useYjs(roomId, { websocketUrl: `ws://localhost:${PORT}` })
  const { remoteStates, updateLocalStateField } = useAwareness<SchemaAwarenessState>(awareness, currentUser)
  
  // 获取 NodeSchemaModel 实例
  const schemaManager = SchemaManager.getInstance()
  const schemaModel = schemaManager.createSchema(roomId, provider.value!)
  
  // 拖拽节点
  const insertSharedNode = (
    node: { node: Node | RootNode; parent: Node | RootNode; data: Node },
    position: PositionType = POSITION.IN
  ) => {
    // ...上面提到的核心逻辑
  }
  
  // ... 其他核心函数
  
  // 组件卸载时取消监听
  onUnmounted(() => {
    schemaManager.destroyObserver(roomId)
    provider.value?.off('sync', () => {})
    // awareness.value?.destroy()
  })
  
  return {
    remoteStates,
    insertSharedNode,
    // ... 其他核心函数
  }
}
  

这样,任何时候 Schema 层执行了插入、删除、修改等操作,都可以直接通过 useCollabSchema() 来同步到共享文档。

总结

在整个多人协同体系中,Yjs 与 Schema 的双向同步机制是 tiny-engine 协作的核心。

  • 正向同步(Yjs → Schema): 通过 observeobserveDeep 监听 Yjs 的数据变更,当远端协作者修改文档时,本地自动更新 Schema,从而触发界面刷新。

  • 反向同步(Schema → Yjs): 通过 Vue Hook 捕获本地用户操作(如插入、删除、修改节点等),再调用封装的 useRealtimeCollab() 方法,将变更同步回 Yjs 文档。

  • 事件总线与 Meta 元数据: 用于解决单纯数据变更中无法还原操作意图的问题。事件总线负责节点级别的创建与删除同步,而 Meta 则用于监听属性与状态的更改。

最终,我们构建出了一条完整的数据同步链路:

Yjs 改动 → Schema 更新 → 视图刷新
Schema 改动 → Yjs 更新 → 远端同步

这条链路确保了多人协同环境下的数据一致性与实时响应能力,让每一个编辑动作都能即时地被所有协作者感知与呈现。 它既保证了操作的语义化,也为后续的冲突解决与版本管理打下了坚实的基础。

实操上手:

接下来,我们将引导您在本地环境中,仅需几条命令,就能启动一个功能完备的协同设计画布,并见证实时同步的“魔法”。

预备工作:你的开发环境

在开始之前,请确保您的本地环境满足以下条件,这是保证顺利运行的基础:

  • Node.js: 版本需 ≥ 16。我们推荐使用 nvmfnm 等工具来管理 Node.js 版本,以避免环境冲突。
    # 检查你的 Node.js 版本
    node -v 
    
  • pnpm: tiny-engine 采用 pnpm 作为包管理器,以充分利用其在 monorepo(多包仓库)项目中的高效依赖管理能力。
    # 如果尚未安装 pnpm,请运行以下命令
    npm install -g pnpm
    

第一步:克隆 tiny-engine 源码

首先,将 tiny-engine 的官方仓库克隆到您的本地。

git clone https://github.com/opentiny/tiny-engine.git
cd tiny-engine

进入项目目录后,您会发现这是一个结构清晰的 monorepo 项目,所有功能模块(如编辑器核心、物料面板、协作服务等)都作为独立的子包存在于 packages/ 目录下。

2️⃣ 第二步:安装项目依赖

在项目根目录下,执行 pnpm install。pnpm 会智能地解析并安装所有子包的依赖,并建立它们之间的符号链接(symlinks)。

pnpm install

💡 为什么是 pnpm? 在 monorepo 架构中,pnpm 通过其独特的非扁平化 node_modules 结构和内容寻址存储,可以极大地节省磁盘空间,并避免“幻影依赖”问题,保证了开发环境的纯净与一致性。

3️⃣ 第三步:启动开发服务,见证奇迹!

一切准备就绪,现在只需运行 dev 命令,即可一键启动整个 tiny-engine 开发环境。

pnpm dev

这个命令背后发生了什么?

  • 它会同时启动多个服务,包括:
    • Vite 前端开发服务器: 负责构建和热更新您在浏览器中看到的编辑器界面。
    • 协作后端服务器 (y-websocket): 一个轻量级的 WebSocket 服务器,负责接收、广播和持久化 Y.js 的协同数据。
  • 终端会输出编辑器前端的访问地址,通常默认为 http://localhost:7007(请以您终端的实际输出为准)。

4️⃣ 第四步:开启你的“多人协作”剧本

现在,是时候扮演不同的协作者了!

  1. 打开第一个窗口: 在您的浏览器(推荐 Chrome)中打开上一步获取的地址,例如 http://localhost:7007。您会看到 tiny-engine 的低代码设计器界面。这就是我们的用户A

image-2.png

  1. 打开第二个窗口: 打开一个新的浏览器隐身窗口,或者使用另一台连接到同一局域网的设备,再次访问相同的地址。这个窗口将扮演用户B

  2. 开始实时协同!: 将两个窗口并排摆放,现在开始您的表演:

    • 在用户A的画布上拖入一个按钮组件。观察用户B的画布,几乎在拖拽完成的瞬间,同样的按钮就会“凭空出现”在相同的位置。
    • 在用户B的界面上,选中刚刚同步过来的按钮,修改它的“按钮内容”属性。观察用户A的界面,按钮的文本会实时地、逐字地发生变化。
    • 在用户A的大纲树面板中,拖拽一个组件来改变其层级结构。观察用户B的大纲树,节点会立即移动到新的位置。
    • 在任意一个窗口中,尝试同时操作。比如,用户A修改组件的颜色,用户B修改其边距。您会发现,由于 CRDT 的特性,所有的修改最终都会被正确合并,达到最终一致的状态,而不会产生冲突或覆盖。

进阶探索与调试技巧

如果您对背后的原理感到好奇,可以尝试以下操作来深入探索:

  • 查看协同状态: 打开浏览器的开发者工具,进入 控制台,你会看到相应的协同状态数据

  • 网络“时光机”: 在开发者工具的 Network 标签页,筛选 WS (WebSocket) 连接。您可以看到客户端与 y-websocket 服务器之间流动的二进制消息。尝试断开网络再重连,观察 Y.js 是如何利用 CRDT 的能力,在重连后自动同步所有离线期间的变更的。

  • 扮演“上帝”: 在控制台中,您可以访问 Y.js 的 docawareness 实例,尝试手动修改数据或广播自定义状态,来更深入地理解数据驱动的协同模型。

通过以上步骤,您已经成功在本地完整地体验了 tiny-engine 先进的多人协作能力。这不仅仅是一个功能演示,它背后融合了 CRDT (Y.js)、实时通信 (WebSocket)、元数据驱动和事件总线 等一系列现代前端工程化的最佳实践。

演示

20251026152240_rec_.gif

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

关于 OpenTiny

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

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

【Vue3】大屏性能优化黑科技:Vue 3 中实现请求合并,让你的大屏飞起来!

前言

作为大屏开发者,你是否遇到过这样的困扰:一个复杂的大屏页面,多个组件同时请求同一个接口,导致浏览器在短时间内发起了 N 次相同的网络请求?这不仅拖慢了大屏的加载速度,还可能导致数据不一致。今天,我将分享一个专为大屏优化的黑科技——请求合并,让你的 Vue 3 大屏应用瞬间提升一个档次!

一、大屏场景:为什么需要请求合并?

想象一下这个场景:

你正在开发一个 工业监控大屏 ,页面上有多个组件需要展示实时生产数据:

  • 顶部状态栏显示总生产量和合格率
  • 左侧设备列表显示所有设备的运行状态
  • 中间图表区域展示生产趋势图
  • 右侧告警面板显示当前告警数量 当大屏加载时,这四个组件都会发起 GET /api/production/stats 请求获取实时生产统计数据。如果没有请求合并,浏览器会在短时间内发起 4 次完全相同的网络请求!

在大屏应用中,这种情况会导致更严重的问题:

  1. 大屏加载缓慢 :多个请求同时发起,网络带宽被占用,导致大屏无法快速呈现
  2. 数据不同步 :不同组件接收到数据的时间不同,导致大屏数据不一致
  3. 服务器压力大 :大屏通常需要实时刷新,频繁的重复请求会给服务器带来巨大压力
  4. 影响实时性 :过多的网络请求会导致数据更新延迟,影响大屏的实时监控效果

二、解决方案:请求合并是什么?

请求合并 是一种专为大屏优化的性能技术,它的核心思想是:

当大屏上的 多个组件同时请求相同数据 时,只执行 一次实际的网络请求 ,然后将结果分发给所有等待的组件。

用一句话概括: 合并相同请求,共享实时数据 。

三、实现原理:如何为大屏实现请求合并?

大屏应用的请求合并实现需要考虑以下特点:

  1. 高频请求 :大屏通常需要频繁刷新数据
  2. 实时数据 :数据更新频率高,需要保证数据的时效性
  3. 多组件共享 :多个可视化组件需要相同的数据
  4. 性能敏感 :大屏对性能要求极高,需要快速响应

我们将使用 Map 来跟踪正在进行的请求,并使用 Promise 来实现结果的分发。

核心实现步骤:

  1. 生成请求唯一标识 :根据请求的 URL、方法、参数等生成唯一的请求键
  2. 检查请求状态 :检查是否有相同的请求正在进行
  3. 复用或发起请求 :如果有,直接复用;如果没有,发起新请求
  4. 分发请求结果 :将请求结果分发给所有等待的组件
  5. 清理请求跟踪 :请求完成后,从跟踪列表中移除

四、代码实现:Vue 3 + Axios 大屏请求合并

1. 核心代码:Axios 请求合并拦截器

// src/api/axios.js
import axios from 'axios';

// 创建axios实例
const instance = axios.create({
  baseURL: '/api', // 大屏API基础地址
  timeout: 5000, // 大屏请求超时时间,通常设置较短
  headers: {
    'Content-Type': 'application/json'
  }
});

// 用于跟踪正在进行的请求
const pendingRequests = new Map();

// 生成请求唯一标识
function generateRequestKey(config) {
  const { method, url, params, data } = config;
  return `${method}_${url}_${JSON.stringify(params)}_${JSON.stringify(data)}`;
}

// 请求拦截器
instance.interceptors.request.use(
  (config) => {
    // 生成请求唯一标识
    const requestKey = generateRequestKey(config);
    
    // 检查是否有相同的请求正在进行
    if (pendingRequests.has(requestKey)) {
      // 如果有,返回正在进行的请求的Promise
      return pendingRequests.get(requestKey);
    }
    
    // 创建一个新的Promise,用于跟踪请求状态
    const requestPromise = new Promise((resolve, reject) => {
      // 存储resolve和reject函数,供响应拦截器使用
      config.resolve = resolve;
      config.reject = reject;
    });
    
    // 将请求Promise存储到pendingRequests中
    pendingRequests.set(requestKey, requestPromise);
    config.requestKey = requestKey;
    
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// 响应拦截器
instance.interceptors.response.use(
  (response) => {
    const { config } = response;
    
    // 从pendingRequests中获取请求Promise
    const requestPromise = pendingRequests.get(config.requestKey);
    if (requestPromise) {
      // 从pendingRequests中移除
      pendingRequests.delete(config.requestKey);
      
      // 使用resolve函数完成Promise,分发结果给所有组件
      if (config.resolve) {
        config.resolve(response);
      }
    }
    
    return response;
  },
  (error) => {
    const { config } = error;
    
    // 处理请求失败的情况
    if (config && config.requestKey) {
      // 从pendingRequests中移除
      pendingRequests.delete(config.requestKey);
      
      // 拒绝所有等待的请求
      if (config.reject) {
        config.reject(error);
      }
    }
    
    return Promise.reject(error);
  }
);

// 导出带请求合并的axios实例
export default instance;

2. 应用示例

<template>
  <div class="app">
    <h1>Vue 3 请求合并示例</h1>
    
    <div class="controls">
      <button @click="fetchMultipleRequests" :disabled="loading">
        {{ loading ? '请求中...' : '同时发起3个相同请求' }}
      </button>
      <button @click="clearResults">清除结果</button>
    </div>
    
    <div class="results">
      <h2>请求结果</h2>
      <div 
        v-for="(result, index) in results" 
        :key="index" 
        class="result-item"
      >
        <p>请求 {{ index + 1 }}: {{ result.status }}</p>
        <p v-if="result.success">数据: {{ JSON.stringify(result.data) }}</p>
        <p v-else>错误: {{ result.error }}</p>
        <p>耗时: {{ result.time }}ms</p>
      </div>
      
      <div class="summary" v-if="results.length > 0">
        <h3>性能总结</h3>
        <p>发起请求数: <strong>{{ results.length }}</strong></p>
        <p>实际网络请求数: <strong>1</strong></p>
        <p>减少请求数: <strong>{{ results.length - 1 }}</strong></p>
      </div>
    </div>
  </div>
</template>

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

// 创建axios实例
const axiosInstance = axios.create({
  timeout: 5000
});

// 用于跟踪正在进行的请求
const pendingRequests = new Map();

// 生成请求唯一标识
function generateRequestKey(config) {
  const { method, url, params, data } = config;
  return `${method}_${url}_${JSON.stringify(params)}_${JSON.stringify(data)}`;
}

// 请求拦截器 - 实现请求合并
axiosInstance.interceptors.request.use(
  (config) => {
    const requestKey = generateRequestKey(config);
    
    if (pendingRequests.has(requestKey)) {
      return pendingRequests.get(requestKey);
    }
    
    const requestPromise = new Promise((resolve, reject) => {
      config.resolve = resolve;
      config.reject = reject;
    });
    
    pendingRequests.set(requestKey, requestPromise);
    config.requestKey = requestKey;
    
    return config;
  },
  (error) => Promise.reject(error)
);

// 响应拦截器 - 分发结果
axiosInstance.interceptors.response.use(
  (response) => {
    const { config } = response;
    const requestPromise = pendingRequests.get(config.requestKey);
    
    if (requestPromise) {
      pendingRequests.delete(config.requestKey);
      if (config.resolve) {
        config.resolve(response);
      }
    }
    
    return response;
  },
  (error) => {
    const { config } = error;
    if (config && config.requestKey) {
      pendingRequests.delete(config.requestKey);
      if (config.reject) {
        config.reject(error);
      }
    }
    return Promise.reject(error);
  }
);

// 状态管理
const results = ref([]);
const loading = ref(false);

// 同时发起多个请求
const fetchMultipleRequests = async () => {
  loading.value = true;
  results.value = [];
  
  try {
    // 同时发起3个相同的请求
    const requests = Array(3).fill().map(async (_, index) => {
      const startTime = Date.now();
      try {
        // 使用公共API进行测试
        const response = await axiosInstance.get('https://jsonplaceholder.typicode.com/todos/1');
        const endTime = Date.now();
        
        return {
          status: '成功',
          success: true,
          data: response.data,
          time: endTime - startTime
        };
      } catch (error) {
        const endTime = Date.now();
        return {
          status: '失败',
          success: false,
          error: error.message,
          time: endTime - startTime
        };
      }
    });
    
    // 等待所有请求完成
    const resultsList = await Promise.all(requests);
    results.value = resultsList;
  } finally {
    loading.value = false;
  }
};

// 清除结果
const clearResults = () => {
  results.value = [];
};
</script>

<style scoped>
.app {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  font-family: Arial, sans-serif;
}

.controls {
  margin: 20px 0;
}

button {
  padding: 10px 20px;
  margin-right: 10px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
}

button:hover {
  background-color: #3aa876;
}

button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.results {
  margin-top: 30px;
}

.result-item {
  margin: 10px 0;
  padding: 15px;
  border: 1px solid #eee;
  border-radius: 4px;
  background-color: #f9f9f9;
}

.summary {
  margin-top: 20px;
  padding: 15px;
  background-color: #e8f5e8;
  border-radius: 4px;
  border-left: 4px solid #42b983;
}
</style>

五、扩展实现:支持 Fetch API

除了 Axios,我们还可以为 Fetch API 实现请求合并,以支持更多大屏场景:

// src/api/fetch.js
// 用于跟踪正在进行的fetch请求
const pendingRequests = new Map();

// 生成请求唯一标识
function generateFetchKey(url, options = {}) {
  const { method = 'GET', headers, body } = options;
  return `${method}_${url}_${JSON.stringify(headers)}_${body}`;
}

/**
 * 带请求合并的fetch包装函数
 */
export const fetchWithMerge = async (url, options = {}) => {
  // 生成请求唯一标识
  const requestKey = generateFetchKey(url, options);
  
  // 检查是否有相同的请求正在进行
  if (pendingRequests.has(requestKey)) {
    // 如果有,返回正在进行的请求的Promise
    return pendingRequests.get(requestKey);
  }
  
  // 创建请求Promise
  const requestPromise = (async () => {
    try {
      // 执行实际的fetch请求
      const response = await fetch(url, options);
      
      // 检查响应是否成功
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      // 返回响应
      return response;
    } finally {
      // 无论请求成功还是失败,都从pendingRequests中移除
      pendingRequests.delete(requestKey);
    }
  })();
  
  // 将请求Promise存储到pendingRequests中
  pendingRequests.set(requestKey, requestPromise);
  
  // 返回请求Promise
  return requestPromise;
};

// 简化的GET请求方法,适合大屏实时数据获取
export const getWithMerge = async (url, options = {}) => {
  const response = await fetchWithMerge(url, { method: 'GET', ...options });
  return response.json();
};

六、大屏效果对比:请求合并带来的性能提升

指标 未使用请求合并 使用请求合并 提升幅度
网络请求次数 5次 1次 80%
数据传输量 5倍 1倍 80%
服务器压力 5倍 1倍 80%
大屏加载时间 取决于最慢的请求 取决于单次请求 显著提升
数据同步性 可能不同步 完全同步 100%

七、大屏应用场景

请求合并特别适合以下大屏场景:

  1. 工业监控大屏:多个组件同时请求设备状态、生产数据等
  2. 城市管理大屏:多个组件同时请求人口、交通、环境等数据
  3. 金融监控大屏:多个组件同时请求股票、汇率、交易数据等
  4. 物流监控大屏:多个组件同时请求货物、车辆、仓库数据等
  5. 能源监控大屏:多个组件同时请求电力、水资源、燃气数据等

八、大屏优化注意事项

  1. 请求标识的唯一性:确保生成的请求标识能准确区分不同的大屏请求
  2. 请求失败的处理:确保请求失败时,所有等待的组件都能得到正确的错误信息
  3. 请求超时的处理:大屏请求超时时间通常设置较短,避免影响用户体验
  4. 内存泄漏:确保请求完成后,从跟踪列表中移除,避免内存泄漏
  5. 实时数据更新:结合定时刷新机制,确保大屏数据的实时性

九、总结:请求合并,让大屏飞起来!

通过实现请求合并,我们可以为大屏应用带来以下好处:

  1. 减少网络请求次数:将多个相同请求合并为一个,减少80%以上的网络请求
  2. 降低服务器压力:减少服务器需要处理的请求数量,提高服务器响应速度
  3. 提升大屏加载速度:减少等待网络请求的时间,让大屏更快呈现
  4. 确保数据一致性:所有组件使用相同的数据,避免数据不一致问题
  5. 优化用户体验:大屏加载更快,数据更同步,用户体验更好

请求合并是一种简单而强大的大屏性能优化技术,它可以在不改变现有代码结构的情况下,显著提升大屏应用的性能。无论是工业监控大屏还是城市管理大屏,请求合并都能带来明显的性能提升。

十、预告:下一篇博客内容

在这篇博客中,我们学习了大屏请求合并的实现方法。在下一篇博客中,我们将学习另一个专为大屏优化的重要技术——数据缓存,它可以:

  • 缓存请求结果,避免短时间内的重复请求
  • 支持自定义缓存时间,适配不同大屏数据的更新频率
  • 结合请求合并,实现更强大的大屏性能优化
  • 支持内存缓存和持久化缓存,满足不同大屏场景的需求

大屏性能优化没有终点,只有不断的探索和实践。让我们一起打造更快、更流畅的大屏应用!🚀

通过<RouterView/>来切换页面组件时,transition如何生效?

场景

在使用Vue提供的transition组件来实现页面切换时的过渡效果时,直接使用了transition来包裹路由,结果发现了一个问题,新页面进入时的动画效果成功实现了,而旧页面离开的动画却失效了。

子页面1:

<template>
    <div class="page1">
        page1
    </div>
</template>

<style scoped>
.page1 {
    width: 100%;
    height: 100%;
    border: 1px solid #000;
    background-color: pink;
    text-align: center;
    font-size: 50px;
}
</style>

子页面2:

<template>
    <div class="page2">
        page2
    </div>
</template>

<style scoped>
.page2 {
    width: 100%;
    height: 100%;
    background-color: blue;
    text-align: center;
    font-size: 50px;
}
</style>

主页面:

<template>
<div class="container">
      <div class="tabs">
    <router-link to="/page1">page1</router-link>
    <router-link to="/page2">page2</router-link>
  </div>
  <div class="page">
      <transition name="fade" mode="out-in">
          </router-view>
      </transition>
  </div>
</div>
</template>

<style>
.container {
  margin: 0 auto;
  width: 800px;
  height: 600px;
  border: 1px solid #000;
}
.page {
  width: 100%;
  height: calc(100% - 60px);
}
.tabs {
  height: 40px;
  width: 200px;
  margin: 0 auto;
  background: green;
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 20px;
  margin-bottom: 20px;
}
a {
  color: #fff;
  font-size: 20px;
}
.fade-enter-active {
  transition: all 0.5s ease-in-out;
}
.fade-enter-from {
  opacity: 0;
  transform: translateX(100%);
}
.fade-enter-to {
  opacity: 1;
  transform: translateX(0);
}
.fade-leave-active {
  transition: all 0.5s ease-in-out;
}
.fade-leave-from {
  opacity: 1;
  transform: translateX(0);
}
.fade-leave-to {
  opacity: 0;
  transform: translateX(-100%);
}
</style>

页面效果如下: 切换页面 .fade-enter相关CSS成功执行,.fade-leave相关CSS执行失败.(如图)

解决方法

后来我从别人那获取到了解决方法:用 RouterView 包裹 Transition 配合 Component 实现过渡效果。

<template>
    <router-view v-slot="{ Component }">
      <transition>
        <component :is="Component" />
      </transition>
    </router-view>
<template/>

改为这样子后,无论是进入还是里离开都能正确执行了。

原因

Vue3 官方文档 Transition一节中,给出了transition组件的触发条件(满足其一):

  • 由 v-if 所触发的切换
  • 由 v-show 所触发的切换
  • 由特殊元素 <component> 切换的动态组件
  • 改变特殊的 key 属性

意思就是说<transition> 组件要正常工作,包裹的内容必须是 “可复用” 的元素或组件

问题根源:router-view 是一个 “动态渲染出口”

其关键在于router-view本身不可复用,它的核心行为如下:

  • 当路由切换时,销毁旧组件实例创建新组件实例
  • router-view 本身不会 “更新”,而是直接替换内部的组件内容。

当直接写 <transition><router-view /></transition> 时,路由更新,transition还没有来得及给旧组件添加离开的效果时,旧的组件实例已经销毁了,这时新的组件实例创建,transition能够正常捕获到该组件实例。而解决方法是通过作用域插槽 "v-slot={Component}" ,把当前路由对应的组件实例暴露出来, 然后使用 component来动态渲染,由于 <component> 本身是一个 “可复用” 的容器,它不会被销毁,只是改变内部渲染的组件。可以让 <transition> 正常监听组件的 “离开” 和 “进入”,从而执行完整的过渡动画。

总结

总之,路由切换过渡动画的核心是让 <transition> 能正常捕获组件的 “离开” 与 “进入” 状态(满足官方给出的触发条件)。避开直接包裹 <router-view> 的误区,同时呢也需要注意样式隔离、动画执行顺序等细节,这样就能实现完整的路由过渡效果。

vue3中createApp多个实例共享状态

1.背景

在 Vue 3 开发中,通常一个应用只需要调用一次 createApp() 创建一个根应用实例。但在某些特定场景下,确实需要创建多个 Vue 应用实例(即多次调用 createApp)。这些场景主要包括:

2.场景

1.动态生成html

说明
比如在使用google地图的时候,点击弹框使用传入一个html弹框内容详情内容。

image.png 上面就是谷歌点击时候提供的弹框内容,使用InfoWindow.open触发弹框,。infoWindow.setContent插入自己要显示的详情内容。

老办法是直接jquery 插各种dom操作。但现在都组件化了如果能复用现有的架构和样式是最理想的。

方案1 createApp

这时候就可以利用createApp创建vue来渲染详情,这样就可以复用系统已经开发好的样式的结构。(缺点重新实例了一遍有一定开销)

示例

  import StoreInfoWindow from './components/StoreInfoWindow.vue'
  let infoWindow: google.maps.InfoWindow
  
  const markerShowDetail = async (marker: google.maps.marker.AdvancedMarkerElement) => {
    try {
      // 调用接口查询详情数据
      const res: any = await getStoreInfo(marker)
      if (res.data && res.data.row) {
        // 详情页面显示
        const storeDetail = res.data.row
        const content = document.createElement('div')
        infoWindow.setContent(content)
        infoWindow.open(map, marker)
        const app = createApp(StoreInfoWindow, { store: xxx })
        app.use(ElementPlus)
        app.mount(content)
      }
    } catch (error) {
      // loading.value = false
      console.error('Error fetching store info:', error)
    }
  }

方案2 隐藏div

当然也可以不使用createApp,直接在现有sfc页面里 插入一个隐藏的div,内容把内容渲染到隐藏div,调用infoWindow.setContent传入dom

  import StoreInfoWindow from './components/StoreInfoWindow.vue'
  
 <div class="hideDiv">
      <StoreInfoWindow ref="storeInfoRef" :store="storeDetail"  ></StoreInfoWindow>
    </div>


  const storeDetail = ref<MapStore>()
  const storeInfoRef = ref()
  let infoWindow: google.maps.InfoWindow
  
  const markerShowDetail = async (marker: google.maps.marker.AdvancedMarkerElement) => {    
    try {
      // 调用接口查询详情数据
      const res: any = await getStoreInfo(marker)
      if (res.data && res.data.row) {
        // 详情页面显示
        storeDetail.value = res.data.row
        if (storeInfoRef.value) {
          nextTick(() => {
            infoWindow.setContent(storeInfoRef.value.$el)
            infoWindow.open(map, marker)
          })
        }
      }
    } catch (error) {
      console.error('Error fetching store info:', error)
    }
  }

由于一个dom节点 不能同时挂在多个不同节点下,所以上面的infoWindow.setContent(storeInfoRef.value.$el) 设置后,hideDiv的下面的内容会被移走。所以关闭时候需要还原回来。防止节点引用丢失。

关闭后,补偿方法

  const infoWindowClose = () => {
    infoWindow.close()
    const hideDiv = document.querySelector('.hideDiv')
    if (hideDiv) {
      if (!hideDiv.contains(storeInfoRef.value.$el)) {
        hideDiv.appendChild(storeInfoRef.value.$el)
      }
    }
  }

2.微前端架构(Micro Frontends)

在微前端架构中,一个页面可能由多个独立的子应用组成,每个子应用可能是由不同的团队开发、使用不同的框架或不同版本的 Vue。为了隔离作用域和避免冲突,每个子应用应拥有自己的 Vue 实例。

1// 子应用 A
2const appA = createApp(AppA);
3appA.mount('#micro-app-a');
4
5// 子应用 B
6const appB = createApp(AppB);
7appB.mount('#micro-app-b');

每个子应用可以独立注册插件、全局组件、指令等,互不影响。


3.在同一个页面嵌入多个独立的 Vue 应用

说明
比如一个传统多页网站(非 SPA)中,某些页面包含多个功能模块(如导航栏、侧边购物车、评论区),它们彼此逻辑独立,不需要共享状态,也不需要通信。

示例

<!-- index.html -->
<div id="header-widget"></div>
<div id="cart-widget"></div>
<div id="comment-section"></div>
// main.js
import { createApp } from 'vue';
import HeaderWidget from './HeaderWidget.vue';
import CartWidget from './CartWidget.vue';
import CommentSection from './CommentSection.vue';

createApp(HeaderWidget).mount('#header-widget');
createApp(CartWidget).mount('#cart-widget');
createApp(CommentSection).mount('#comment-section');

每个 widget 是一个独立的 Vue 应用,可单独开发、测试、部署。


4.插件或第三方库需要隔离的 Vue 实例

说明
当你开发一个 Vue 插件(如 UI 组件库中的弹窗、通知等),而该插件内部需要渲染 Vue 组件时,为避免污染主应用的全局配置(如全局指令、混入、provide/inject 等),应创建独立的 Vue 实例。

示例(封装一个全局 Toast 组件):

// toast.js
import { createVNode, render } from 'vue';
import ToastComponent from './Toast.vue';

export function showToast(message) {
  const container = document.createElement('div');
  document.body.appendChild(container);

  const vm = createVNode(ToastComponent, { message });
  const app = createApp({}); // 创建干净实例
  app.mount(container);
  render(vm, container);
}

这样 Toast 不会继承主应用的全局配置,更安全可靠。

5.单元测试或多实例沙箱环境

说明
在编写测试用例时,为避免测试之间互相干扰,每个测试用例应使用独立的 Vue 应用实例。

示例(Vitest / Jest):

test('Component A works', () => {
  const app = createApp(ComponentA);
  const div = document.createElement('div');
  app.mount(div);
  // ...断言
  app.unmount();
});

test('Component B works', () => {
  const app = createApp(ComponentB); // 全新实例,无污染
  // ...
});

3.createApp 构造方式

我们复习一下 创建的方式

1.传入 SFC(单文件组件)【最常用】

传入 .vue 文件作为根组件

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

带 root props 的方式

createApp(App, { title: 'Hello' }).mount('#app')

SFC 内:

<script setup>
defineProps({
  title: String
})
</script>

2.传入 Options API 对象(构造对象组件)

不用 SFC,直接传一个对象:

直接传组件对象

createApp({
  data() {
    return { msg: 'Hello' }
  },
  template: `<div>{{ msg }}</div>`
}).mount('#app')

带 root props

createApp({
  props: ['title'],
  template: `<h1>{{ title }}</h1>`
}, {
  title: 'Hello Props'
}).mount('#app')

3.传入 Render Function(函数式创建根组件)

使用 h() 渲染函数

import { createApp, h } from 'vue'

createApp({
  render() {
    return h('div', 'Hello from render')
  }
}).mount('#app')

带 root props 的 render 写法

createApp({
  props: ['msg'],
  render(props) {
    return h('div', props.msg)
  }
}, {
  msg: 'Hello props'
})
.mount('#app')

4.传入 Template 字符串(inline 模板)

适用于快速 demo:

根组件直接写 template 字符串

createApp({
  template: `<p>Hello Template</p>`
}).mount('#app')

root props + template

createApp({
  props: ['text'],
  template: `<p>{{ text }}</p>`
}, {
  text: 'Hello!'
}).mount('#app')

4.数据共享问题

由于两个app 是独立的沙盒,但是我们又需要同步部分数据状态

1.全局变量(简单场景,不推荐大型项目)

通过浏览器全局对象(window)存储共享数据,利用 Vue 的响应式 API(ref/reactive)保证数据变更能触发视图更新。

<script>
  const { createApp, ref } = Vue;

  // 1. 定义全局共享的响应式数据
  window.sharedState = ref({
    username: 'Vue开发者',
    count: 0
  });

  // 2. 应用实例1:使用全局共享数据
  createApp({
    setup() {
      const shared = window.sharedState;
      const increment = () => shared.value.count++;
      return { shared, increment };
    },
    template: `
      <div>
        <h3>应用1 - 计数:{{ shared.count }}</h3>
        <button @click="increment">+1</button>
      </div>
    `
  }).mount('#app1');

  // 3. 应用实例2:共享同一份数据
  createApp({
    setup() {
      const shared = window.sharedState;
      const changeName = () => shared.value.username = '新名称';
      return { shared, changeName };
    },
    template: `
      <div>
        <h3>应用2 - 用户名:{{ shared.username }}</h3>
        <h3>应用2 - 同步计数:{{ shared.count }}</h3>
        <button @click="changeName">修改用户名</button>
      </div>
    `
  }).mount('#app2');
</script>

2.事件总线

通过第三方事件库(如 mitt)实现跨实例的 “发布 - 订阅” 通信,适用于需要触发行为 / 传递临时数据的场景(而非持久化共享状态)。

步骤:

  1. 安装 mitt(工程化项目):npm install mitt
  2. 创建全局事件总线实例;
  3. 不同应用实例通过 emit 发布事件,on 监听事件传递数据。
<!-- CDN 方式示例 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/mitt/dist/mitt.umd.js"></script>
<script>
  const { createApp, ref } = Vue;
  // 1. 创建全局事件总线
  window.eventBus = mitt();

  // 应用1:发布事件(传递数据)
  createApp({
    setup() {
      const count = ref(0);
      const sendCount = () => {
        count.value++;
        // 发布事件,携带数据
        window.eventBus.emit('count-change', count.value);
      };
      return { count, sendCount };
    },
    template: `<button @click="sendCount">应用1发送计数</button>`
  }).mount('#app1');

  // 应用2:监听事件(接收数据)
  createApp({
    setup() {
      const receiveCount = ref(0);
      // 监听事件,接收数据
      window.eventBus.on('count-change', (val) => {
        receiveCount.value = val;
      });
      return { receiveCount };
    },
    template: `<div>应用2接收的计数:{{ receiveCount }}</div>`
  }).mount('#app2');
</script>

3.Pinia/Vuex

Pinia(Vue 3 官方推荐)/ Vuex 是专门的状态管理库,可创建全局共享的状态仓库,多个应用实例通过访问同一仓库实现数据共享(最规范的方案)。

创建全局 Pinia

// src/store/index.js
import { createPinia, defineStore } from 'pinia';

// 1. 创建全局 Pinia 实例(唯一)
export const pinia = createPinia();

// 2. 定义共享仓库
export const useSharedStore = defineStore('shared', {
  state: () => ({
    count: 0,
    message: 'Pinia 共享数据'
  }),
  actions: {
    increment() {
      this.count++;
    },
    updateMessage(newMsg) {
      this.message = newMsg;
    }
  }
});

多个应用实例挂载同一 Pinia 并使用仓库

// src/app1.js(应用实例1)
import { createApp } from 'vue';
import { pinia, useSharedStore } from './store';
import App1 from './App1.vue';

const app1 = createApp(App1);
// 挂载全局 Pinia 实例
app1.use(pinia);
// 组件内使用仓库
// App1.vue 中:
// setup() { const store = useSharedStore(); store.increment(); }
app1.mount('#app1');

// src/app2.js(应用实例2)
import { createApp } from 'vue';
import { pinia, useSharedStore } from './store';
import App2 from './App2.vue';

const app2 = createApp(App2);
// 挂载同一个 Pinia 实例
app2.use(pinia);
// App2.vue 中可直接访问同一份仓库数据
app2.mount('#app2');

组件内使用示例(App1.vue):

<template>
  <div>
    <h3>应用1 - {{ store.message }}</h3>
    <p>计数:{{ store.count }}</p>
    <button @click="store.increment">+1</button>
  </div>
</template>

<script setup>
import { useSharedStore } from './store';
const store = useSharedStore();
</script>

组件内使用示例(App2.vue):

<template>
  <div>
    <h3>应用2 - {{ store.message }}</h3>
    <p>同步计数:{{ store.count }}</p>
    <button @click="store.updateMessage('应用2修改了消息')">修改消息</button>
  </div>
</template>

<script setup>
import { useSharedStore } from './store';
const store = useSharedStore();
</script>

4.共享响应式对象

1. 非sfc方式

直接创建一个独立的响应式对象(ref/reactive),作为多个应用实例的 “数据源”,本质是将响应式数据抽离到实例外部。

<script>
  const { createApp, ref } = Vue;

  // 1. 抽离共享的响应式数据(独立于应用实例)
  const sharedData = ref({
    count: 0,
    text: '共享响应式数据'
  });

  // 应用1:使用共享数据
  createApp({
    setup() {
      const increment = () => sharedData.value.count++;
      return { sharedData, increment };
    },
    template: `<div>应用1:{{ sharedData.count }} <button @click="increment">+1</button></div>`
  }).mount('#app1');

  // 应用2:使用同一份共享数据
  createApp({
    setup() {
      const changeText = () => sharedData.value.text = '应用2修改';
      return { sharedData, changeText };
    },
    template: `<div>应用2:{{ sharedData.text }} / {{ sharedData.count }} <button @click="changeText">改文本</button></div>`
  }).mount('#app2');
</script>

2.sfc的方式

image.png

<template>
  <div class="container">
    <h1>Vue 3 共享Ref示例</h1>

    <!-- 主应用组件 -->
    <div class="main-app">
      <h2>主应用</h2>
      <p>共享计数: {{ sharedCount }}</p>
      <p>标题: {{ title }}</p>
      <button @click="incrementCount">增加计数</button>
      <button @click="changeTitle">修改标题</button>
    </div>

    <!-- 动态创建的组件容器 -->
    <div id="dynamic-component"></div>
  </div>
</template>

<script setup>
  import { ref, onMounted, onUnmounted, watch } from 'vue'
  import { createApp } from 'vue'

  // 子组件定义
  const ChildComponent = {
    template: `
    <div class="child-component">
      <h3>动态创建的子组件</h3>
      <p>共享计数: {{ count }}</p>
      <p>标题: {{ title }}</p>
      <button @click="decrementCount">减少计数</button>
      <button @click="resetTitle">重置标题</button>
    </div>
  `,
    props: {
      count: {
        // 这里要传入ref类型
        type: Object,
        required: true,
      },
      title: {
        // 这里要传入ref类型
        type: Object,
        required: true,
      },
      onDecrement: {
        type: Function,
        required: true,
      },
      onResetTitle: {
        type: Function,
        required: true,
      },
    },
    methods: {
      decrementCount() {
        this.onDecrement()
      },
      resetTitle() {
        this.onResetTitle()
      },
    },
  }

  // 创建共享的ref
  const sharedCount = ref(0)
  const title = ref('Hello')
  let dynamicApp = null

  const incrementCount = () => {
    sharedCount.value++
  }

  const decrementCount = () => {
    if (sharedCount.value > 0) {
      sharedCount.value--
    }
  }

  const changeTitle = () => {
    title.value = `标题已修改 ${new Date().toLocaleTimeString()}`
  }

  const resetTitle = () => {
    title.value = 'Hello'
  }

  // 动态应用的根组件
  const DynamicRoot = {
    template: '<ChildComponent :count="count" :title="title" :on-decrement="onDecrement" :on-reset-title="onResetTitle" />',
    components: {
      ChildComponent,
    },
    props: {
      count: Number,
      title: String,
      onDecrement: Function,
      onResetTitle: Function,
    },
  }

  onMounted(() => {
    // 使用createApp(App, props)的写法创建动态应用
    dynamicApp = createApp(DynamicRoot, {
      count: sharedCount,
      title: title,
      onDecrement: decrementCount,
      onResetTitle: resetTitle,
    })

    // 挂载到DOM
    dynamicApp.mount('#dynamic-component')
  })

  onUnmounted(() => {
    // 清理动态创建的应用
    if (dynamicApp) {
      dynamicApp.unmount()
    }
  })
</script>

<style scoped>
  .container {
    max-width: 600px;
    margin: 0 auto;
    padding: 20px;
    font-family: Arial, sans-serif;
  }

  .main-app,
  .child-component {
    border: 2px solid #e0e0e0;
    border-radius: 8px;
    padding: 20px;
    margin: 20px 0;
    background-color: #f9f9f9;
  }

  .child-component {
    border-color: #007bff;
    background-color: #f0f8ff;
  }

  button {
    background-color: #007bff;
    color: white;
    border: none;
    padding: 10px 20px;
    border-radius: 4px;
    cursor: pointer;
    margin: 5px;
  }

  button:hover {
    background-color: #0056b3;
  }

  h1,
  h2,
  h3 {
    color: #333;
  }
</style>

注意 子组件需要使用ref类型作为参数,因为是根节点

Vue3 脚本革命:<script setup> 让你的代码简洁到飞起!

你是不是还在为 Vue 组件的那些繁琐语法头疼?每次写个组件都要 export default、methods、data 来回折腾,感觉代码总是啰里啰嗦的?

告诉你个好消息,Vue3 的 <script setup> 语法糖简直就是为我们这些追求效率的开发者量身定做的!它能让你用更少的代码做更多的事,而且写起来那叫一个爽快。

今天我就带你彻底搞懂这个功能,从基本用法到高级技巧,保证让你看完就能用上,代码量直接减半!

什么是 <script setup>

简单来说,<script setup> 是 Vue3 引入的一种编译时语法糖,它能让单文件组件的脚本部分变得更加简洁明了。

以前我们写个组件得这样:

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

现在用 <script setup> 就简单多了:

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

const count = ref(0)

const increment = () => {
  count.value++
}
</script>

看出来了吧?代码一下子清爽了很多!不再需要那些模板化的结构,直接写逻辑就行。

为什么要用 <script setup>

你可能要问,我已经习惯原来的写法了,为什么要换呢?这里给你几个无法拒绝的理由:

代码量大幅减少,不用再写那些重复的样板代码。组件间的数据传递和事件处理变得更加直观。更好的 TypeScript 支持,类型推断更加准确。编译时优化,性能更优秀。

最重要的是,写起来真的很快乐!你再也不用在 methods、data、computed 之间来回切换了。

基础用法速成

让我们从最简单的开始,一步步掌握 <script setup> 的核心用法。

定义响应式数据,在 <script setup> 里,我们直接用 ref 和 reactive:

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

// 基本类型用 ref
const name = ref('张三')
const age = ref(25)

// 对象类型可以用 reactive
const userInfo = reactive({
  job: '前端开发',
  salary: 20000
})

// 修改数据也很简单
const updateInfo = () => {
  name.value = '李四'  // ref 需要通过 .value 访问
  userInfo.salary = 25000 // reactive 直接修改属性
}
</script>

定义方法就更简单了,直接写函数就行:

<script setup>
const sayHello = () => {
  console.log('你好,Vue3!')
}

const calculate = (a, b) => {
  return a + b
}
</script>

计算属性也用起来:

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

const price = ref(100)
const quantity = ref(2)

// 计算总价
const total = computed(() => {
  return price.value * quantity.value
})

// 复杂的计算属性
const discountTotal = computed(() => {
  const totalVal = price.value * quantity.value
  return totalVal > 200 ? totalVal * 0.9 : totalVal
})
</script>

组件通信变得超简单

<script setup> 里,组件间的通信也变得特别直观。

定义 props 可以用 defineProps:

<script setup>
// 基础用法
defineProps(['title', 'content'])

// 带类型检查的用法
defineProps({
  title: String,
  content: {
    type: String,
    required: true
  },
  count: {
    type: Number,
    default: 0
  }
})

// 用 TypeScript 的话更简单
defineProps<{
  title?: string
  content: string
  count?: number
}>()
</script>

定义 emits 用 defineEmits:

<script setup>
// 基础用法
const emit = defineEmits(['update', 'delete'])

// 带验证的用法
const emit = defineEmits({
  update: (id) => {
    if (id) return true
    console.warn('需要提供 id')
    return false
  }
})

// 实际使用
const handleUpdate = () => {
  emit('update', 123)
}

const handleDelete = () => {
  emit('delete', 456)
}
</script>

高级技巧让你更专业

掌握了基础用法,再来看看一些提升效率的高级技巧。

使用组合式函数,这是 Vue3 的精髓之一:

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

// 封装一个获取数据的组合式函数
const useFetch = (url) => {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const fetchData = async () => {
    loading.value = true
    try {
      const response = await fetch(url)
      data.value = await response.json()
    } catch (err) {
      error.value = err
    } finally {
      loading.value = false
    }
  }

  onMounted(fetchData)

  return {
    data,
    loading,
    error,
    refetch: fetchData
  }
}

// 在组件中使用
const { data: userData, loading, error } = useFetch('/api/user')
</script>

使用 defineExpose 暴露组件方法:

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

const count = ref(0)

const increment = () => {
  count.value++
}

const reset = () => {
  count.value = 0
}

// 暴露给父组件的方法
defineExpose({
  increment,
  reset
})
</script>

使用 useSlots 和 useAttrs:

<script setup>
import { useSlots, useAttrs } from 'vue'

const slots = useSlots()
const attrs = useAttrs()

// 可以动态处理插槽和属性
const hasHeaderSlot = !!slots.header
const extraClass = attrs.class || ''
</script>

实战案例:打造一个任务管理器

光说不练假把式,我们来写个完整的任务管理组件:

<template>
  <div class="task-manager">
    <div class="add-task">
      <input 
        v-model="newTask" 
        @keyup.enter="addTask"
        placeholder="输入新任务..."
        class="task-input"
      >
      <button @click="addTask" class="add-btn">添加</button>
    </div>
    
    <div class="task-list">
      <div 
        v-for="task in filteredTasks" 
        :key="task.id"
        :class="['task-item', { completed: task.completed }]"
      >
        <input 
          type="checkbox" 
          v-model="task.completed"
          class="task-checkbox"
        >
        <span class="task-text">{{ task.text }}</span>
        <button @click="removeTask(task.id)" class="remove-btn">删除</button>
      </div>
    </div>
    
    <div class="task-stats">
      <span>总计: {{ totalTasks }} 个任务</span>
      <span>已完成: {{ completedTasks }} 个</span>
      <button @click="filter = 'all'">全部</button>
      <button @click="filter = 'active'">未完成</button>
      <button @click="filter = 'completed'">已完成</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'

// 响应式数据
const newTask = ref('')
const tasks = ref([])
const filter = ref('all')

// 添加新任务
const addTask = () => {
  if (newTask.value.trim()) {
    tasks.value.push({
      id: Date.now(),
      text: newTask.value.trim(),
      completed: false
    })
    newTask.value = ''
    saveToLocalStorage()
  }
}

// 删除任务
const removeTask = (id) => {
  tasks.value = tasks.value.filter(task => task.id !== id)
  saveToLocalStorage()
}

// 计算属性
const totalTasks = computed(() => tasks.value.length)
const completedTasks = computed(() => 
  tasks.value.filter(task => task.completed).length
)

const filteredTasks = computed(() => {
  switch (filter.value) {
    case 'active':
      return tasks.value.filter(task => !task.completed)
    case 'completed':
      return tasks.value.filter(task => task.completed)
    default:
      return tasks.value
  }
})

// 本地存储
const saveToLocalStorage = () => {
  localStorage.setItem('vue-tasks', JSON.stringify(tasks.value))
}

const loadFromLocalStorage = () => {
  const saved = localStorage.getItem('vue-tasks')
  if (saved) {
    tasks.value = JSON.parse(saved)
  }
}

// 生命周期
onMounted(() => {
  loadFromLocalStorage()
})
</script>

<style scoped>
.task-manager {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
}

.task-input {
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  margin-right: 8px;
}

.add-btn {
  padding: 8px 16px;
  background: #4CAF50;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.task-item {
  display: flex;
  align-items: center;
  padding: 8px;
  border-bottom: 1px solid #eee;
}

.task-item.completed .task-text {
  text-decoration: line-through;
  color: #888;
}

.task-checkbox {
  margin-right: 8px;
}

.task-text {
  flex: 1;
}

.remove-btn {
  padding: 4px 8px;
  background: #ff4757;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.task-stats {
  margin-top: 16px;
  display: flex;
  gap: 12px;
  align-items: center;
}
</style>

这个例子展示了 <script setup> 在实际项目中的强大能力,代码结构清晰,逻辑组织得当。

常见问题解答

Q: 从 Options API 迁移到 <script setup> 难吗? A: 其实不难!大部分概念都是相通的,只是写法更简洁了。建议先从简单的组件开始尝试。

Q: <script setup> 对 TypeScript 支持怎么样? A: 支持非常好!类型推断更加准确,写起来特别舒服。

Q: 还能和普通的 <script> 混用吗? A: 可以的,但通常不建议。除非你有特殊的模块导出需求。

Q: 现有的 Vue2 项目能直接用吗? A: 需要升级到 Vue3,但升级过程比想象中简单,官方提供了详细的迁移指南。

最佳实践推荐

根据我的经验,这些实践能让你的代码质量更高:

按逻辑组织代码,而不是按功能类型。把相关的数据、方法、计算属性放在一起。合理使用组合式函数抽离可复用逻辑。使用 TypeScript 获得更好的开发体验。保持组件的单一职责,不要写太复杂的组件。

耗时一周,我把可视化+零代码+AI融入到了CRM系统,使用体验超酷!

最近花了一周时间,配合AI,打磨了一款CRM客户管理系统——NO-CRM。

图片

客户关系管理(CRM)系统的核心价值在于以客户为中心,通过数字化手段打通 “获客 - 转化 - 留存 - 复购 - 推荐” 全链路,帮助企业降本增效、提升客户价值与市场竞争力。

做这款CRM系统之前,我研究了市面上比较流行的商业产品,结合了我之前设计的零代码理念,做了一款从客户管理,数据分析,到用户收集,再到工作流设计的一整套解决方案,大家可以直接部署使用。

图片

我们可以直接在CRM中在线设计各种收集表单:

图片

后台自带了表单收集和统计分析功能,同时还能设计工作流:

图片

并自定义工作流和审批条件:

图片

当然还有AI分析模块,我们可以通过AI帮我们分析线索数据:

图片

我已经把这个CRM系统镜像开源,大家可以直接安装或者部署到服务器直接使用。

接下来就和大家一起分享一下我做的这款全栈CRM 系统。

✨ 特性

  • 🎨 现代化 UI - 基于 TDesign Vue Next,提供精美的企业级界面
  • 📊 数据可视化 - ECharts 驱动的数据大屏和图表分析
  • 🔐 完善的权限系统 - RBAC 权限模型,支持角色、部门、用户细粒度权限控制
  • 🤖 AI 智能助手 - 集成 AI 功能,提供智能推荐和辅助决策
  • 🔄 工作流引擎 - 可视化流程设计器,支持复杂业务流程编排
  • 📝 表单设计器 - 拖拽式表单设计,支持多种字段类型和校验规则
  • 📱 移动端适配 - 完美支持各种设备,响应式设计
  • 💾 轻量化存储 - 基于 JSON 文件存储,无需复杂数据库配置
  • 🚀 开箱即用 - 简单配置即可快速部署上线
  • 🔧 高度可定制 - 模块化设计,易于扩展和二次开发

技术栈

image.png

前端技术

技术 版本 说明
Vue 3 3.5.13 渐进式 JavaScript 框架
TypeScript 5.7.3 JavaScript 的超集,提供类型安全
Vite 6.0.5 下一代前端构建工具
TDesign Vue Next 1.10.6 腾讯企业级组件库
Pinia 2.3.0 Vue 官方状态管理库
Vue Router 4.5.0 Vue 官方路由管理器
ECharts 6.0.0 强大的数据可视化库
Vue Flow 1.47.0 流程图编辑器
Axios 1.7.9 HTTP 客户端

后端技术

技术 版本 说明
NestJS 11.0.1 渐进式 Node.js 框架
TypeScript 5.7.3 类型安全的开发体验
Passport JWT 4.0.1 JWT 身份验证策略
Bcrypt 5.1.1 密码加密库
Multer 2.0.2 文件上传中间件
Class Validator 0.14.2 基于装饰器的参数验证

已实现功能

  • 用户认证
    • 用户注册与登录
    • JWT token 认证
    • 角色权限控制(管理员/销售)
  • 客户管理
    • 客户列表查看与搜索
    • 新建、编辑、删除客户
    • 客户详情查看
    • 标签管理
  • 线索管理
    • 线索状态流转(未跟进→跟进中→已合格→已成交/无效)
    • 意向等级管理
    • 线索筛选
  • 跟进记录
    • 多种跟进方式(电话、邮件、会议等)
    • 时间线展示
    • 下次跟进提醒
  • 任务管理
    • 待办事项管理
    • 优先级设置
    • 到期提醒
    • 任务状态切换
  • 文件上传
    • 支持图片、PDF、Word、Excel 文件上传
    • 客户附件管理
    • 文件在线预览和下载
  • 数据大屏
    • 实时统计数据展示
    • Echarts 图表可视化
    • 多维度数据分析
  • 其他功能
    • 分页支持(所有列表)
    • Mock 数据生成
    • 数据搜索和筛选

当然对于企业团队来说,组织部门管理也是必备的,NO-CRM也实现了动态创建组织部门的功能,并能基于组织部门设置单独的权限:

图片

当然还有很多高价值的功能,大家可以线上体验:

好啦,今天的分享就到这,如果你有好的建议,欢迎留言区交流反馈~

Vue响应式原理(13)-ref实现原理解析

Vue 3 中 Ref 实现原理解析

在 Vue 3 中,ref 是组合式 API(Composition API)的核心。很多开发者虽然会用,但对其内部运作机制、refreactive 的关系、以及为什么在scrit中我们访问 ref 数据需要用 .value, 但是在模板里不需要 .value 往往一知半解。

本文将剥离复杂的边界情况,用最精简的代码还原 Vue 3 源码的核心逻辑,带你彻底搞懂这三个问题:

  1. ref 是如何实现的?
  2. toRefs 是如何解决解构丢失响应性问题的?
  3. 为什么在模板中不需要 .value

1. Ref 的原理解析

为什么需要 Ref?

在先前的部分中,我们对响应式数据的原理进行了介绍,我们通过 reactive 函数处理一个对象来使其转变为响应式数据,而对于 JavaScript 中的原始类型(String, Number, Boolean, ...)是值传递的。如果你把一个数字传给一个函数,函数无法追踪这个数字的变化。为了让原始值变成“响应式”,我们需要把它包裹在一个对象中(Wrapper Pattern),利用对象的 gettersetter 来拦截访问和修改。

核心实现:RefImpl

Vue 3 内部通过 RefImpl 类来实现 ref

// 伪代码:简化版的 RefImpl
class RefImpl {
  private _value: any;
  private _rawValue: any;
  public dep: Dep; // 依赖容器
  public __v_isRef = true; // 标记这是一个 ref 对象

  constructor(value) {
    this._rawValue = value;
    // 如果传入的是对象,则通过 reactive 转换,否则保持原值
    this._value = isObject(value) ? reactive(value) : value;
    this.dep = new Set(); // 假设这是依赖收集容器
  }

  get value() {
    // 1. 依赖收集 (Track)
    trackEffects(this.dep); 
    return this._value;
  }

  set value(newVal) {
    // 只有值发生改变时才触发
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal;
      // 如果新值是对象,同样需要转换
      this._value = isObject(newVal) ? reactive(newVal) : newVal;
      // 2. 派发更新 (Trigger)
      triggerEffects(this.dep); 
    }
  }
}

// 暴露出来的 ref 函数
function ref(value) {
  return new RefImpl(value);
}

关键点解析

  1. ref 本质上会返回一个类的实例对象,这个对象拥有 .value 的访问器属性。
  2. __v_isRef:RefImpl 类需要增加一个 __v_isRef 属性用于区别 “Ref对象”与“含有 value 属性的普通对象”。ref 的本质是一个拥有 .value 属性的对象,但并不是所有拥有 .value 的对象都是 ref。如果不增加这个标识位,很难区分下面二者的区别:
// 真正的 ref
const realRef = ref(1); 
// realRef 结构: { value: 1, dep: Set, __v_isRef: true, ... }

// 用户不小心定义的普通对象
const fakeRef = { value: 1 };
// fakeRef 结构: { value: 1 }

Vue 的模板系统或者 reactive 尝试“自动解包”(读取 .value)时,如果没有 __v_isRef,系统可能会错误地把用户定义的 fakeRef 也当作响应式对象处理,去尝试读取它的依赖(dep),这会导致报错或逻辑混乱。

  1. Getter/Setter
    • get value():当访问 .value 时,调用 track 收集当前副作用函数(Effect)。
    • set value():当修改 .value 时,比较新旧值,若变化则调用 trigger 通知视图更新。
  2. 兼容对象参数:如果 ref(obj) 接收的是一个对象,源码中会调用 reactive(obj) 将其转化为深层响应式对象。这就是为什么 ref 可以包裹对象,且对象内部属性变化也能触发更新。

2. toRefs 的原理解析

为什么需要 toRefs?

当我们对一个 reactive 对象进行解构时,会丢失响应性,因为解构出来的只是普通的变量。

const state = reactive({ count: 1 });
const { count } = state; // count 此时只是一个普通数字 1,与 state 断开联系了

toRefs 的作用就是把 reactive 对象的每一个属性都转换成一个 ref,但这个 ref 比较特殊,它链接到了源对象。

核心实现:ObjectRefImpl

toRefs 内部并不是创建标准的 RefImpl,而是创建了 ObjectRefImpl。它不存储值,只是作为源对象属性的“代理”。

class ObjectRefImpl {
  public __v_isRef = true; // 标记为 ref

  constructor(
    private readonly _object, // 源 reactive 对象
    private readonly _key     // 指定的 key
  ) {}

  get value() {
    // 访问时,直接读取源对象的属性
    // 因为 _object 是响应式的,所以这里会自动触发源对象的依赖收集
    return this._object[this._key];
  }

  set value(newVal) {
    // 修改时,直接修改源对象的属性
    // 这里会自动触发源对象的更新派发
    this._object[this._key] = newVal;
  }
}

// toRef 函数:针对单个属性
function toRef(object, key) {
  return new ObjectRefImpl(object, key);
}

// toRefs 函数:遍历对象所有属性
function toRefs(object) {
  const ret = Array.isArray(object) ? new Array(object.length) : {};
  
  for (const key in object) {
    // 为每个属性创建一个 ObjectRefImpl
    ret[key] = toRef(object, key);
  }
  
  return ret;
}

关键点解析

  1. ObjectRefImpl 自身没有任何 tracktrigger 的逻辑。它只是把操作转发给了源 reactive 对象。当我们获取到 toRef 函数返回的对象时,我们对其 .value 属性的读写实际上会转发到对 this._object[this._key] 的读写,自然就会触发其 tracktrigger 的逻辑。
  2. toRefs 返回的是一个普通对象,里面的值全是 ref。这个普通对象可以被解构,解构出来的变量依然是 ObjectRefImpl 实例,依然保持着对源对象的引用。

3. 模板自动解包 (Unwrapping) 原理解析

现象

setup 中我们需要用 count.value,但在 <template> 中我们直接写 {{ count }} 即可。这是 Vue 在编译和渲染阶段做了特殊处理。

核心实现:proxyRefs

首先需要介绍两个辅助函数:

// 如果是 ref 返回 .value,否则返回原值
function unref(ref) {
  return isRef(ref) ? ref.value : ref;
}
// 根据对象 __v_isRef 属性判断其是否是 ref 对象
export function isRef(r: any): r is Ref {
  return !!(r && r.__v_isRef === true);
}

unref 函数首先判断传入的是否是 ref 对象,如果是则返回 ref.value, 否则返回 ref 本身,这个函数正是模板自动解包原理的核心。

Vue 在完成对模板的解析之后,将 setup 的返回值传递给渲染函数之前,会通过 proxyRefs 函数对其进行一层代理,在代理中拦截了 get 和 set 操作,并通过 unref 函数

const shallowUnwrapHandlers = {
  get: (target, key, receiver) => {
    // 1. 获取真实的值
    const value = Reflect.get(target, key, receiver);
    // 2. 自动解包:如果是 ref 就返回 value.value,否则直接返回
    return unref(value);
  },
  
  set: (target, key, value, receiver) => {
    const oldValue = target[key];
    // 3. 特殊处理:如果旧值是 ref,但新值不是 ref
    // 意味着用户想给 ref 赋值:count.value = 1
    if (isRef(oldValue) && !isRef(value)) {
      oldValue.value = value;
      return true;
    } 
    // 其他情况直接替换
    return Reflect.set(target, key, value, receiver);
  }
};

// Vue 内部会在 setupState 上套这一层 Proxy
function proxyRefs(objectWithRefs) {
  return new Proxy(objectWithRefs, shallowUnwrapHandlers);
}

运行流程

  1. 建立代理:当 setup() 函数返回一个对象(包含 ref)时,Vue 内部调用 handleSetupResult,使用 proxyRefs 包装这个返回对象,生成 render context(渲染上下文)。
  2. 模板读取
    • 当模板渲染遇到 {{ count }} 时,实际上是去在这个 Proxy 对象上取 count
    • 触发 get 拦截:发现 count 是一个 ref,Proxy 自动帮你调用 .value 并返回结果。
  3. 模板赋值(例如 v-model):
    • 如果在模板中写 <input v-model="count">
    • 触发 set 拦截:Proxy 发现 count 原本是 ref,而输入的是普通值,它会将新值赋值给 count.value

总结

特性 核心实现类/函数 关键原理
ref RefImpl 利用 getter/setter 劫持 .value 属性,通过 track/trigger 管理依赖。若值为对象则借助 reactive
toRefs ObjectRefImpl 不存值,仅仅是对源 reactive 对象属性的代理访问。解决了解构导致的响应性丢失问题。
模板解包 proxyRefs 利用 Proxy 拦截 setup 返回对象的访问,遇到 ref 自动返回 .value,实现由模板到数据的无感读写。

通过阅读这部分源码,我们可以看到 Vue 3 在易用性(自动解包)和灵活性(ref/reactive 分离)之间做了非常精妙的设计。

vue2、vue3父子组件嵌套生命周期执行顺序

vue2版本

生命周期:

beforeCreate created beforeMount mounted beforeUpdate updated beforeDestroy destroyed activated deactivated

1. 组件挂载阶段(Mounting)

执行顺序:


// 创建和挂载过程

父组件 beforeCreate

父组件 created

父组件 beforeMount

  子组件 beforeCreate

  子组件 created

  子组件 beforeMount

  子组件 mounted

父组件 mounted

代码示例:

<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <ChildComponent />
  </div>
</template>

<script>
import ChildComponent from './Child.vue'

export default {
  name: 'Parent',
  components: { ChildComponent },
  beforeCreate() {
    console.log('1. 父组件 beforeCreate')
  },
  created() {
    console.log('2. 父组件 created')
  },
  beforeMount() {
    console.log('3. 父组件 beforeMount')
  },
  mounted() {
    console.log('6. 父组件 mounted')
  }
}
</script>

<!-- Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
  </div>
</template>

<script>
export default {
  name: 'Child',
  beforeCreate() {
    console.log('4. 子组件 beforeCreate')
  },
  created() {
    console.log('4.1 子组件 created')
  },
  beforeMount() {
    console.log('4.2 子组件 beforeMount')
  },
  mounted() {
    console.log('5. 子组件 mounted')
  }
}
</script>

2. 组件更新阶段(Updating)

总结:

vue2中,只要子组件使用了,父组件传入的值,当该值更新时,子组件的更新生命周期就会执行,其他情况,子组件的更新生命周期都不会执行

更新父组件--子组件使用了,父组件传入的参数

执行顺序:
// 更新过程(数据变化时)

父组件 beforeUpdate

  子组件 beforeUpdate

  子组件 updated

父组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent :childData="parentData" />
  </div>
</template>

<script>
import ChildComponent from './Child.vue'

export default {
  components: { ChildComponent },
  data() {
    return {
      parentData: '初始值'
    }
  },
  methods: {
    changeData() {
      this.parentData = '新值'
    }
  },
  beforeUpdate() {
    console.log('1. 父组件 beforeUpdate')
  },
  updated() {
    console.log('4. 父组件 updated')
  }
}
</script>

<!-- Child.vue -->
<template>
  <div>
    <h3>子组件 - {{ childData }}</h3>
  </div>
</template>

<script>
export default {
  props: ['childData'],
  beforeUpdate() {
    console.log('2. 子组件 beforeUpdate')
  },
  updated() {
    console.log('3. 子组件 updated')
  }
}
</script>

更新父组件--子组件没有使用,父组件传入的参数(即使有传入值给子组件)

执行顺序:
// 更新过程(数据变化时)

父组件 beforeUpdate

父组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent :childData="parentData" />
    <!-- <ChildComponent :childData="parentData" /> -->
  </div>
</template>
<script>
import ChildComponent from '@/components/two/Child.vue'
export default {
  components: { ChildComponent },
  data() {
    return {
      parentData: '初始值'
    }
  },
  methods: {
    changeData() {
      this.parentData = '新值'
    }
  },
  beforeUpdate() {
    console.log('1. 父组件 beforeUpdate')
  },
  updated() {
    console.log('4. 父组件 updated')
  }
}
</script>



<!-- Child.vue -->
<template>
  <div>
    <!-- <h3>子组件 - {{ childData }}</h3> -->
    <h3>子组件</h3>
  </div>
</template>
<script>
export default {
  props: ['childData'],
  beforeUpdate() {
    console.log('2. 子组件 beforeUpdate')
  },
  updated() {
    console.log('3. 子组件 updated')
  }
}
</script>


更新子组件

执行顺序:
// 更新过程(数据变化时)

子组件 beforeUpdate

子组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent/>
  </div>
</template>
<script>
import ChildComponent from '@/components/two/Child.vue'
export default {
  components: { ChildComponent },
  data() {
    return {
      parentData: '初始值'
    }
  },
  methods: {
    changeData() {
      this.parentData = '新值'
    }
  },
  beforeUpdate() {
    console.log('1. 父组件 beforeUpdate')
  },
  updated() {
    console.log('4. 父组件 updated')
  }
}
</script>


<!-- Child.vue -->
<template>
  <div style="border: 1px solid #ccc; padding: 10px; margin: 10px;">
    <h2>Child Component - {{ childMessage }}</h2>
    <button @click="changeChildData">改变子组件数据</button>
    <p>计数: {{ count }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      childMessage: '子组件初始数据',
      count: 0
    }
  },
  methods: {
    changeChildData() {
      this.childMessage = '子组件数据已更新'
      this.count++
      console.log('=== 子组件数据变化触发 ===')
    }
  },
  beforeUpdate() { console.log('Child beforeUpdate') },
  updated() { console.log('Child updated') },

}
</script>



3. 组件销毁阶段(Destroying)

销毁父组件

执行顺序:
// 销毁过程

父组件 beforeDestroy

  子组件 beforeDestroy

  子组件 destroyed

父组件 destroyed
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <ChildComponent v-if="showChild" />
    <button @click="showChild = false">销毁子组件</button>
  </div>
</template>
<script>
import ChildComponent from '@/components/two/Child.vue'
export default {
  components: { ChildComponent },
  data() {
    return {
      showChild: true
    }
  },
  beforeDestroy() {
    console.log('父组件 beforeDestroy')
  },
  destroyed() {
    console.log('父组件 destroyed')
  }
}
</script>

销毁子组件

执行顺序:
// 销毁过程

 子组件 beforeDestroy

 子组件 destroyed

代码示例:见销毁父组件的代码

vue3版本

生命周期:

setup()替代 beforeCreate setup()替代 created onBeforeMount onMounted onBeforeUpdate onUpdated onBeforeUnmount onUnmounted onActivated onDeactivated onRenderTracked(新增) onRenderTriggered(新增)

1. 组件挂载阶段(Mounting)

执行顺序:

1. 父组件 setup (相当于 beforeCreate + created)
2. 父组件 onBeforeMount
    3. 子组件 setup
    3.1 子组件 onBeforeMount
    4. 子组件 onMounted
5. 父组件 onMounted

代码示例:

<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <ChildComponent />
  </div>
</template>
<script setup>
import { onBeforeMount, onMounted } from 'vue'
import ChildComponent from '../Son/s1.vue'
console.log('1. 父组件 setup (相当于 beforeCreate + created)')
onBeforeMount(() => {
  console.log('2. 父组件 onBeforeMount')
})
onMounted(() => {
  console.log('5. 父组件 onMounted')
})
</script>


<!-- Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
  </div>
</template>
<script setup>
import { onBeforeMount, onMounted } from 'vue'
console.log('3. 子组件 setup')
onBeforeMount(() => {
  console.log('3.1 子组件 onBeforeMount')
})
onMounted(() => {
  console.log('4. 子组件 onMounted')
})
</script>

2. 组件更新阶段(Updating)

总结:

vue3中,只要父组件往子组件传入了值,当该值更新时,子组件的更新生命周期就会执行,其他情况,子组件的更新生命周期都不会执行

更新父组件--子组件使用了,父组件传入的参数

执行顺序:
// 更新过程(数据变化时)

父组件 beforeUpdate

  子组件 beforeUpdate

  子组件 updated

父组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent :childData="parentData" />
  </div>
</template>
<script setup>
import { ref, onBeforeUpdate, onUpdated } from 'vue'
import ChildComponent from '../Son/s1.vue'
const parentData = ref('初始值')
const changeData = () => {
  parentData.value = '新值'
}
onBeforeUpdate(() => {
  console.log('1. 父组件 onBeforeUpdate')
})
onUpdated(() => {
  console.log('4. 父组件 onUpdated')
})
</script>


<!-- Child.vue -->
<template>
  <div>
    <h3>子组件 - {{ props.childData }}</h3>
  </div>
</template>
<script setup>
import { onBeforeUpdate, onUpdated, defineProps } from 'vue'

const props = defineProps(['childData'])

onBeforeUpdate(() => {
  console.log('2. 子组件 onBeforeUpdate')
})

onUpdated(() => {
  console.log('3. 子组件 onUpdated')
})

</script>

更新父组件--父组件传值给子组件,即使子组件没有使用 和 定义对应的属性

如果父组件没有传值给子组件,即使子组件定义 和 使用了值,当父组件更新时,子组件也不会更新,即:生命周期是:

父组件 beforeUpdate -> 父组件 updated

执行顺序:
// 更新过程(数据变化时)

父组件 beforeUpdate

  子组件 beforeUpdate

  子组件 updated

父组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件 - {{ parentData }}</h2>
    <button @click="changeData">改变数据</button>
    <ChildComponent :childData="parentData" />
    <!-- <ChildComponent  /> -->
  </div>
</template>
<script setup>
import { ref, onBeforeUpdate, onUpdated } from 'vue'
import ChildComponent from '../Son/s1.vue'
const parentData = ref('初始值')
const changeData = () => {
  parentData.value = '新值'
}
onBeforeUpdate(() => {
  console.log('1. 父组件 onBeforeUpdate')
})
onUpdated(() => {
  console.log('4. 父组件 onUpdated')
})
</script>


<!-- Child.vue -->
<template>
  <div>
    <h3>子组件</h3>
    <!-- <h3>子组件 - {{ props.childData }}</h3> -->
  </div>
</template>
<script setup>
import { onBeforeUpdate, onUpdated, defineProps } from 'vue'

// const props = defineProps(['childData'])

onBeforeUpdate(() => {
  console.log('2. 子组件 onBeforeUpdate')
})

onUpdated(() => {
  console.log('3. 子组件 onUpdated')
})
</script>

更新子组件

执行顺序:
// 更新过程(数据变化时)

子组件 beforeUpdate

子组件 updated
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <!-- <h2>父组件 - {{ parentData }}</h2> -->
    <!-- <button @click="changeData">改变数据</button> -->
    <ChildComponent :childData="parentData" />
    <!-- <ChildComponent  /> -->
  </div>
</template>
<script setup>
import { ref, onBeforeUpdate, onUpdated } from 'vue'
import ChildComponent from '../Son/s1.vue'
const parentData = ref('初始值')
const changeData = () => {
  parentData.value = '新值'
}
onBeforeUpdate(() => {
  console.log('1. 父组件 onBeforeUpdate')
})
onUpdated(() => {
  console.log('4. 父组件 onUpdated')
})
</script>


<!-- Child.vue -->
<template>
  <div>
    <!-- <h3>子组件</h3> -->
    <h2>子组件 - {{ sonData }}</h2>
    <button @click="changeData">改变数据</button>
  </div>
</template>
<script setup>
import { onBeforeUpdate, onUpdated,ref, defineProps } from 'vue'

// const props = defineProps(['childData'])

const sonData = ref('初始值')
const changeData = () => {
  sonData.value = '新值'
}

onBeforeUpdate(() => {
  console.log('2. 子组件 onBeforeUpdate')
})

onUpdated(() => {
  console.log('3. 子组件 onUpdated')
})
</script>

3. 组件销毁阶段(Destroying)

销毁父组件

执行顺序:
// 销毁过程

父组件 beforeDestroy

  子组件 beforeDestroy

  子组件 destroyed

父组件 destroyed
代码示例:
<!-- Parent.vue -->
<template>
  <div>
    <h2>父组件</h2>
    <ChildComponent v-if="showChild" />
    <button @click="showChild = false">卸载子组件</button>
  </div>
</template>
<script setup>
import { ref, onBeforeUnmount, onUnmounted } from 'vue'
import ChildComponent from '../Son/s1.vue'
const showChild = ref(true)
onBeforeUnmount(() => {
  console.log('父组件 onBeforeUnmount')
})
onUnmounted(() => {
  console.log('父组件 onUnmounted')
})
</script>



<!-- Child.vue -->
<template>
  <div>
    <!-- <h3>子组件</h3> -->
    <h2>子组件</h2>
  </div>
</template>
<script setup>
import { onBeforeUnmount, onUnmounted,ref, defineProps } from 'vue'

onBeforeUnmount(() => {
  console.log('子组件 onBeforeUnmount')
})
onUnmounted(() => {
  console.log('子父组件 onUnmounted')
})
</script>

销毁子组件

执行顺序:
// 销毁过程

 子组件 beforeDestroy

 子组件 destroyed

代码示例:见销毁父组件的代码

Vue 3 定时器清理的最佳实践

Vue 3 定时器清理的最佳实践

在 Vue 3 中,清理定时器的最佳位置取决于组件的使用场景和定时器的用途。我将设计一个直观的示例来演示不同生命周期钩子中定时器的清理方式。设计的定时器截图如下

image.png

设计思路

  • 展示不同生命周期钩子中定时器的创建和清理
  • 提供可视化界面展示定时器状态
  • 允许用户手动创建和清理定时器
  • 演示组件卸载时的自动清理

功能说明

这个示例演示了在 Vue 3 中管理定时器的最佳实践:

  1. 定时器创建和清理

    • onMounted 钩子中创建定时器
    • onUnmounted 钩子中清理定时器(必须)
    • 也可在 onBeforeUnmount 中清理
  2. 生命周期演示

    • 通过切换子组件显示/隐藏来演示组件卸载时的定时器清理
    • 在控制台输出生命周期事件
  3. 手动管理

    • 提供手动创建和清理定时器的功能
    • 显示所有活动定时器的状态和进度
  4. 最佳实践代码示例

    • 展示在 Vue 3 组件中正确管理定时器的代码模式

这个示例强调了在 Vue 3 中,无论定时器是在哪个生命周期创建的,都必须在 onUnmountedonBeforeUnmount 中清理,以防止内存泄漏。

最终实现代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vue 3 定时器生命周期管理</title>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        }
        
        body {
            background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
            color: #fff;
            min-height: 100vh;
            padding: 20px;
        }
        
        .container {
            max-width: 1200px;
            margin: 0 auto;
            background: rgba(0, 0, 0, 0.7);
            border-radius: 15px;
            padding: 30px;
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
        }
        
        header {
            text-align: center;
            margin-bottom: 30px;
        }
        
        h1 {
            font-size: 2.5rem;
            margin-bottom: 10px;
            text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
        }
        
        .subtitle {
            font-size: 1.2rem;
            opacity: 0.8;
            margin-bottom: 20px;
        }
        
        .content {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 30px;
        }
        
        @media (max-width: 768px) {
            .content {
                grid-template-columns: 1fr;
            }
        }
        
        .card {
            background: rgba(255, 255, 255, 0.1);
            border-radius: 10px;
            padding: 20px;
            margin-bottom: 20px;
        }
        
        .card h2 {
            margin-bottom: 15px;
            color: #fdbb2d;
            border-bottom: 1px solid rgba(255, 255, 255, 0.2);
            padding-bottom: 10px;
        }
        
        .timer-controls {
            display: flex;
            flex-wrap: wrap;
            gap: 15px;
            margin-bottom: 20px;
        }
        
        .control-group {
            flex: 1;
            min-width: 200px;
        }
        
        label {
            display: block;
            margin-bottom: 8px;
            font-weight: 600;
        }
        
        input, select, button {
            width: 100%;
            padding: 12px;
            border: none;
            border-radius: 5px;
            font-size: 1rem;
        }
        
        input, select {
            background: rgba(255, 255, 255, 0.9);
        }
        
        button {
            background: #4CAF50;
            color: white;
            cursor: pointer;
            transition: all 0.3s;
            font-weight: bold;
            margin-top: 10px;
        }
        
        button:hover {
            background: #45a049;
            transform: translateY(-2px);
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
        }
        
        .danger-btn {
            background: #f44336;
        }
        
        .danger-btn:hover {
            background: #d32f2f;
        }
        
        .warning-btn {
            background: #ff9800;
        }
        
        .warning-btn:hover {
            background: #f57c00;
        }
        
        .timer-list {
            margin-top: 20px;
        }
        
        .timer-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            background: rgba(255, 255, 255, 0.1);
            padding: 15px;
            margin-bottom: 10px;
            border-radius: 8px;
            transition: all 0.3s;
        }
        
        .timer-item:hover {
            background: rgba(255, 255, 255, 0.15);
            transform: translateX(5px);
        }
        
        .timer-info {
            flex: 1;
        }
        
        .timer-id {
            font-weight: bold;
            font-size: 1.1rem;
        }
        
        .timer-details {
            display: flex;
            gap: 15px;
            margin-top: 5px;
            font-size: 0.9rem;
            opacity: 0.8;
        }
        
        .timer-actions {
            display: flex;
            gap: 10px;
        }
        
        .timer-actions button {
            margin: 0;
            padding: 8px 15px;
            width: auto;
        }
        
        .status {
            padding: 5px 10px;
            border-radius: 20px;
            font-size: 0.8rem;
            font-weight: bold;
        }
        
        .status-active {
            background: #4CAF50;
        }
        
        .status-cleared {
            background: #f44336;
        }
        
        .lifecycle-info {
            margin-top: 30px;
            padding: 20px;
            background: rgba(0, 0, 0, 0.3);
            border-radius: 10px;
        }
        
        .lifecycle-info h3 {
            margin-bottom: 15px;
            color: #fdbb2d;
        }
        
        .lifecycle-info ul {
            padding-left: 20px;
            margin-bottom: 15px;
        }
        
        .lifecycle-info li {
            margin-bottom: 8px;
            line-height: 1.5;
        }
        
        .highlight {
            color: #fdbb2d;
            font-weight: bold;
        }
        
        .component-demo {
            margin-top: 30px;
            padding: 20px;
            border: 2px dashed rgba(255, 255, 255, 0.3);
            border-radius: 10px;
        }
        
        .toggle-btn {
            background: #2196F3;
            width: 100%;
        }
        
        .toggle-btn:hover {
            background: #0b7dda;
        }
        
        .stats {
            display: flex;
            justify-content: space-between;
            margin-top: 30px;
            padding-top: 20px;
            border-top: 1px solid rgba(255, 255, 255, 0.2);
        }
        
        .stat-box {
            text-align: center;
            flex: 1;
        }
        
        .stat-value {
            font-size: 2rem;
            font-weight: bold;
            margin-bottom: 5px;
        }
        
        .stat-label {
            font-size: 0.9rem;
            opacity: 0.8;
        }
        
        .empty-state {
            text-align: center;
            padding: 40px;
            opacity: 0.7;
        }
        
        .pulse {
            animation: pulse 2s infinite;
        }
        
        @keyframes pulse {
            0% { transform: scale(1); }
            50% { transform: scale(1.05); }
            100% { transform: scale(1); }
        }
        
        .progress-bar {
            height: 5px;
            background: rgba(255, 255, 255, 0.2);
            border-radius: 5px;
            margin-top: 10px;
            overflow: hidden;
        }
        
        .progress {
            height: 100%;
            background: #4CAF50;
            width: 0%;
            transition: width 0.5s;
        }
        
        .code-block {
            background: rgba(0, 0, 0, 0.5);
            padding: 15px;
            border-radius: 5px;
            font-family: monospace;
            margin: 15px 0;
            overflow-x: auto;
        }
    </style>
</head>
<body>
    <div id="app">
        <div class="container">
            <header>
                <h1>Vue 3 定时器生命周期管理</h1>
                <p class="subtitle">演示在不同生命周期钩子中创建和清理定时器的最佳实践</p>
            </header>
            
            <div class="content">
                <div>
                    <div class="card">
                        <h2>定时器控制面板</h2>
                        <div class="timer-controls">
                            <div class="control-group">
                                <label for="timerType">定时器类型</label>
                                <select id="timerType" v-model="timerType">
                                    <option value="timeout">setTimeout (一次性)</option>
                                    <option value="interval">setInterval (重复)</option>
                                </select>
                            </div>
                            
                            <div class="control-group">
                                <label for="timerDuration">持续时间 (毫秒)</label>
                                <input type="number" id="timerDuration" v-model.number="timerDuration" min="100" max="100000">
                            </div>
                            
                            <div class="control-group">
                                <label for="timerMessage">定时器消息</label>
                                <input type="text" id="timerMessage" v-model="timerMessage" placeholder="输入定时器执行时显示的消息">
                            </div>
                        </div>
                        
                        <button @click="addTimer" class="pulse">添加定时器</button>
                        <button @click="clearAllTimers" class="danger-btn">清理所有定时器</button>
                    </div>
                    
                    <div class="card">
                        <h2>活动定时器 ({{ activeTimersCount }})</h2>
                        <div class="timer-list">
                            <div v-if="activeTimers.length === 0" class="empty-state">
                                暂无活动定时器
                            </div>
                            <div v-else v-for="timer in activeTimers" :key="timer.id" class="timer-item">
                                <div class="timer-info">
                                    <div class="timer-id">定时器 #{{ timer.id }}</div>
                                    <div class="timer-details">
                                        <span>类型: {{ timer.type === 'timeout' ? 'setTimeout' : 'setInterval' }}</span>
                                        <span>持续时间: {{ timer.duration }}ms</span>
                                        <span>消息: "{{ timer.message }}"</span>
                                    </div>
                                    <div class="progress-bar">
                                        <div class="progress" :style="{ width: timer.progress + '%' }"></div>
                                    </div>
                                </div>
                                <div class="timer-actions">
                                    <span class="status status-active">活动</span>
                                    <button class="danger-btn" @click="clearTimer(timer.id)">清理</button>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
                
                <div>
                    <div class="card">
                        <h2>生命周期演示</h2>
                        <div class="lifecycle-info">
                            <h3>Vue 3 定时器清理最佳实践</h3>
                            <ul>
                                <li><span class="highlight">onMounted</span> - 在组件挂载后创建定时器</li>
                                <li><span class="highlight">onUnmounted</span> - 在组件卸载前清理定时器(必须)</li>
                                <li><span class="highlight">onBeforeUnmount</span> - 在组件卸载前清理定时器的替代方案</li>
                                <li><span class="highlight">watchEffect</span> - 响应式地创建和清理定时器</li>
                                <li><span class="highlight">手动清理</span> - 在需要时手动清理特定定时器</li>
                            </ul>
                            
                            <div class="code-block">
// 最佳实践示例<br>
import { onMounted, onUnmounted, ref } from 'vue'<br><br>

const timerId = ref(null)<br><br>

onMounted(() => {<br>
&nbsp;&nbsp;// 创建定时器<br>
&nbsp;&nbsp;timerId.value = setInterval(() => {<br>
&nbsp;&nbsp;&nbsp;&nbsp;// 定时器逻辑<br>
&nbsp;&nbsp;}, 1000)<br>
})<br><br>

onUnmounted(() => {<br>
&nbsp;&nbsp;// 清理定时器<br>
&nbsp;&nbsp;if (timerId.value) {<br>
&nbsp;&nbsp;&nbsp;&nbsp;clearInterval(timerId.value)<br>
&nbsp;&nbsp;}<br>
})
                            </div>
                        </div>
                        
                        <div class="component-demo">
                            <h3>组件卸载演示</h3>
                            <p>点击按钮切换子组件显示/隐藏,观察控制台输出</p>
                            <button class="toggle-btn" @click="toggleComponent">
                                {{ showChildComponent ? '隐藏' : '显示' }}子组件
                            </button>
                            
                            <div v-if="showChildComponent">
                                <child-component></child-component>
                            </div>
                        </div>
                    </div>
                    
                    <div class="stats">
                        <div class="stat-box">
                            <div class="stat-value">{{ activeTimersCount }}</div>
                            <div class="stat-label">活动定时器</div>
                        </div>
                        <div class="stat-box">
                            <div class="stat-value">{{ clearedTimersCount }}</div>
                            <div class="stat-label">已清理定时器</div>
                        </div>
                        <div class="stat-box">
                            <div class="stat-value">{{ totalTimersCount }}</div>
                            <div class="stat-label">总定时器</div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script>
        const { createApp, ref, onMounted, onUnmounted, computed, onBeforeUnmount } = Vue;
        
        // 子组件演示生命周期
        const ChildComponent = {
            template: `
                <div class="card" style="margin-top: 15px;">
                    <h3>子组件生命周期演示</h3>
                    <p>组件状态: <span style="color: #4CAF50;">已挂载</span></p>
                    <p>定时器ID: {{ timerId || '无' }}</p>
                    <p>计时: {{ count }} 秒</p>
                    <button class="warning-btn" @click="stopTimer">停止定时器</button>
                </div>
            `,
            setup() {
                const timerId = ref(null);
                const count = ref(0);
                
                // 在 onMounted 中创建定时器
                onMounted(() => {
                    console.log('子组件已挂载,创建定时器');
                    timerId.value = setInterval(() => {
                        count.value++;
                        console.log(`子组件定时器执行: ${count.value} 秒`);
                    }, 1000);
                });
                
                // 在 onUnmounted 中清理定时器 (最佳实践)
                onUnmounted(() => {
                    console.log('子组件即将卸载,清理定时器');
                    if (timerId.value) {
                        clearInterval(timerId.value);
                        console.log('定时器已清理');
                    }
                });
                
                // 也可以在 onBeforeUnmount 中清理
                onBeforeUnmount(() => {
                    console.log('onBeforeUnmount: 清理定时器');
                });
                
                const stopTimer = () => {
                    if (timerId.value) {
                        clearInterval(timerId.value);
                        timerId.value = null;
                        console.log('手动停止定时器');
                    }
                };
                
                return {
                    timerId,
                    count,
                    stopTimer
                };
            }
        };
        
        const app = createApp({
            components: {
                ChildComponent
            },
            setup() {
                // 定时器数据
                const timers = ref([]);
                const timerIdCounter = ref(1);
                const timerType = ref('timeout');
                const timerDuration = ref(3000);
                const timerMessage = ref('定时器已触发!');
                const showChildComponent = ref(false);
                
                // 计算属性
                const activeTimers = computed(() => 
                    timers.value.filter(t => t.status === 'active')
                );
                
                const activeTimersCount = computed(() => activeTimers.value.length);
                
                const clearedTimersCount = computed(() => 
                    timers.value.filter(t => t.status === 'cleared').length
                );
                
                const totalTimersCount = computed(() => timers.value.length);
                
                // 添加定时器
                const addTimer = () => {
                    if (timerDuration.value < 100) {
                        alert('请输入有效的持续时间(至少100毫秒)');
                        return;
                    }
                    
                    const timerId = timerIdCounter.value++;
                    let timerRef;
                    
                    // 创建定时器对象
                    const timerObj = {
                        id: timerId,
                        type: timerType.value,
                        duration: timerDuration.value,
                        message: timerMessage.value,
                        status: 'active',
                        startTime: Date.now(),
                        progress: 0
                    };
                    
                    // 根据类型设置定时器
                    if (timerType.value === 'timeout') {
                        timerRef = setTimeout(() => {
                            handleTimerCompletion(timerId);
                            console.log(`定时器 #${timerId}: ${timerMessage.value}`);
                        }, timerDuration.value);
                        
                        timerObj.ref = timerRef;
                    } else {
                        timerRef = setInterval(() => {
                            console.log(`定时器 #${timerId}: ${timerMessage.value}`);
                        }, timerDuration.value);
                        
                        timerObj.ref = timerRef;
                    }
                    
                    timers.value.push(timerObj);
                    updateProgressBars();
                };
                
                // 处理定时器完成
                const handleTimerCompletion = (timerId) => {
                    const timer = timers.value.find(t => t.id === timerId);
                    if (timer) {
                        timer.status = 'completed';
                    }
                };
                
                // 清理单个定时器
                const clearTimer = (timerId) => {
                    const timer = timers.value.find(t => t.id === timerId);
                    if (timer && timer.status === 'active') {
                        if (timer.type === 'timeout') {
                            clearTimeout(timer.ref);
                        } else {
                            clearInterval(timer.ref);
                        }
                        timer.status = 'cleared';
                        console.log(`定时器 #${timerId} 已清理`);
                    }
                };
                
                // 清理所有定时器
                const clearAllTimers = () => {
                    if (timers.value.length === 0) {
                        alert('没有活动定时器可清理');
                        return;
                    }
                    
                    if (confirm(`确定要清理所有 ${timers.value.length} 个定时器吗?`)) {
                        timers.value.forEach(timer => {
                            if (timer.status === 'active') {
                                if (timer.type === 'timeout') {
                                    clearTimeout(timer.ref);
                                } else {
                                    clearInterval(timer.ref);
                                }
                                timer.status = 'cleared';
                            }
                        });
                        
                        console.log('所有定时器已清理');
                    }
                };
                
                // 更新进度条
                const updateProgressBars = () => {
                    const activeTimersList = timers.value.filter(t => t.status === 'active');
                    
                    activeTimersList.forEach(timer => {
                        const elapsed = Date.now() - timer.startTime;
                        const progress = Math.min(100, (elapsed / timer.duration) * 100);
                        timer.progress = progress;
                        
                        // 如果是interval类型,进度条会循环
                        if (timer.type === 'interval' && progress >= 100) {
                            timer.startTime = Date.now();
                        }
                    });
                };
                
                // 切换子组件显示
                const toggleComponent = () => {
                    showChildComponent.value = !showChildComponent.value;
                };
                
                // 设置一个定时器来更新进度条
                onMounted(() => {
                    setInterval(updateProgressBars, 100);
                });
                
                return {
                    timers,
                    timerType,
                    timerDuration,
                    timerMessage,
                    showChildComponent,
                    activeTimers,
                    activeTimersCount,
                    clearedTimersCount,
                    totalTimersCount,
                    addTimer,
                    clearTimer,
                    clearAllTimers,
                    toggleComponent
                };
            }
        });
        
        app.mount('#app');
    </script>
</body>
</html>

搞懂虚拟列表实现原理与步骤

虚拟列表已经说烂了,此篇文章仅作记录使用,通俗的拆解每一步的逻辑和每一个变量的意义。

一、原理和基本构成

首先,虚拟滚动就是为了解决渲染大量数据到页面上造成的性能问题,一千个dom元素同时渲染到页面上必然出现卡顿,但是一千条或者一万条真的渲染出来了,一般的显示器也是显示不出来的,那我们能不能只渲染可视区域中出现的数据缩小渲染数据量呢,比如我的可视区域只能展示十条,那我把这十条拿出来只渲染这十条不就好了。那这种虚拟列表就应运而生了。

他的结构如下图:

image.png

没看懂没关系,它的结构代码如下:

<template>
 <!-- 可视区域 -->
  <div class="virtua_main">
    <!-- 虚拟元素 -->
    <div class="occupy_pace"></div>
     <!-- 内容区域 -->
    <div class="virtua_content">
     
      <p class="virtua_item">
        item1
      </p>
      
    </div>
    
  </div>
</template>

<style scoped>
.virtua_main {
  width: 500px;
  height: 500px;
  overflow-y: auto;
  position: relative;
  background: greenyellow;
  color: red;
}

.virtua_content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.virtua_item {
  margin: 0;
  border: 1px solid #e0e0e0;
  box-sizing: border-box;
}
</style>

首先明确,可视区域(virtua_main)的高度是固定的,超出高度出现滚动条,所以需要一个虚拟元素(occupy_pace)撑起高度从而显示滚动条,而且滚动高度要与数据条数相匹配。内容区域(virtua_content)通过绝对定位覆盖在虚拟元素之上。

二、需要思考的几个数字

到目前为止,只有可视区域的高度是固定的,那么内容区域的高度呢,虚拟元素的高度呢;下面列阵:

内容区域的高度 = 要展示的条数 * 每一条内容的高度

虚拟元素的高度 = 需要渲染数组的总长度 * 每一条内容的高度

截取展示内容数据开始的索引 = 滚动条移动的高度 / 每一条内容的高度

截取展示内容数据结束的索引 = 截取展示内容数据开始的索引 + 要展示的条数

三、代码实现基础逻辑

虽然但是那我还是不知道,要展示的条数、每一条内容的高度、滚动条移动的高度、需要渲染数组的总长度从哪里来呢?那还是直接上代码吧🙄🙄🙄

<template>
  <!-- 可视区域 -->
  <div class="virtua_main" @scroll="onScroll">
    <!-- 虚拟元素 -->
    <div class="occupy_pace" :style="{ height: virtualHeight }"></div>
    <!-- 内容区域 -->
    <div
      class="virtua_content"
      :style="{ height: contentHeight, '--row-height': rowHeight + 'px' }"
    >
      <p class="virtua_item" v-for="item in visibleItems" :key="item.id">
        {{ item.label }}
      </p>
    </div>
  </div>
</template>

<script setup>
import { ref } from "vue";
//模拟一千条数据
const allList = ref(
  Array.from({ length: 1000 }).map((_, index) => ({
    id: index,
    label: `Item ${index + 1}`,
  }))
);
//可视区域显示条数
const showSize = ref(10);
//每行高度
const rowHeight = ref(50);
//当前滚动高度
const scrollTop = ref(0);
//虚拟元素高度
const virtualHeight = computed(() => {
  return allList.value.length * rowHeight.value + "px";
});
//内容区高度
const contentHeight = computed(() => {
  return showSize.value * rowHeight.value + "px";
});
//截取展示内容数据开始的索引
const startIndex = computed(() => {
  //开始的索引要考虑边界问题,不能比0小,同时不能大于数据总长度减显示条数
  //使用floor向下取整数
  const index = Math.floor(scrollTop.value / rowHeight.value);
  const maxStartIndex = Math.max(0, allList.value.length - showSize.value);
  return Math.min(index, maxStartIndex);
});
//截取展示内容数据结束的索引
const endIndex = computed(() => {
  //也有边界问题,不能大于数据总长度
  return Math.min(startIndex.value + showSize.value, allList.value.length);
});
//内容区域展示的数据,从所有数据中截取showSize条
const visibleItems = computed(() => {
  return allList.value.slice(startIndex.value, endIndex.value);
});
//滚动事件
const onScroll = (event) => {
  //将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
  scrollTop.value = event.target.scrollTop;
};
</script>

<style scoped>
.virtua_main {
  width: 300px;
  height: 500px;
  overflow-y: auto;
  position: relative;
  background: #e0e0e0;
  color: red;
}

.virtua_content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.virtua_item {
  height: var(--row-height);
  margin: 0;
  border: 1px solid #000;
  box-sizing: border-box;
}
</style>

这样更直观的回答了数据从哪来、如何计算、如何使用、何时更新的问题。但是但是但是如果这样写了之后会发现一个问题,内容数据确实一直在变,但是内容区域怎么上去了,位置不对,如图:

嘻嘻嘻.gif

上图出现的原因是因为虽然内容区域已经开启了定位,但是它处于virtua_main的滚动空间之中,所以也会随着滚动,那这样,既然他要向上滚动,那我们就再加一步,手动通过transform:translateY()把他纵向向下移动,那么应该移动的距离怎么计算呢,理一理,比如当前从0到19展示了10条,我们向下滚动到1此时0隐藏了,那他是不是就向上滚动了1 * rowHeight的距离呢,1是当前切割数据的开始索引,由此推断 位移距离 = startIndex * rowHeight==

在滚动事件增加这么一行

//通过ref的方式取到内容区域
const virtuaContent = ref(null)
//滚动事件
const onScroll = (event) => {
  //将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
  scrollTop.value = event.target.scrollTop;
  //向下位移掩盖空白
  virtuaContent.value.style.transform = `translateY(${
    startIndex.value * rowHeight.value
  }px)`;
};

目前为止,完成了一个基础的虚拟滚动。

四、细节问题

1.空白

大家是否想过,如果我滚动高度挪到了一个不能整除rowHeight的数字怎么办,当然我们使用了Math.floor向下取整,比如我挪动到了624,我设置的rowHeight是50,startIndex = 624 / 50结果是12.48,Math.floor(12.48)得出12,很好数字没有问题,但是我们使用transform进行位移是用12 * 50得600,也就是说我向上滚动了624px但是向下却位移了600px,那尾部自然会出现24px的空白,如图:

image.png

两种解决方案

其一,简单粗暴,算位移距离直接用scrollTop:

//通过ref的方式取到内容区域
const virtuaContent = ref(null)
//滚动事件
const onScroll = (event) => {
  //将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
  scrollTop.value = event.target.scrollTop;
  //向下位移掩盖空白
  virtuaContent.value.style.transform = `translateY(${
    (scrollTop.value / rowHeight.value) * rowHeight.value
  }px)`;
};

其二、更简单更粗暴,尾部多切几条数据:

//定义缓冲数据大小
const buffer = 3;
//截取展示内容数据结束的索引
const endIndex = computed(() => {
  //也有边界问题,不能大于数据总长度
  //每次多加载buffer条数据
  return Math.min(
    startIndex.value + showSize.value + buffer,
    allList.value.length
  );
});

这两种方式都能用,但使用 scrollTop 会产生半行高度空白,需要 buffer 补齐

2.加载更多

我们做到这里基本能用了,但是都说了大数据量了,只有一千条吗,我有十万条!一百万!但是一个接口返回给你这个请求的时间是不是有点长呢,那么一页一千条拉到底部继续加载是不是极好的呢,如果再加一个等待效果是不是极好的呢。如果马上滑到底部就发送请求到底部时数据已经加载出来了是不是堪称完美,那我们来实现他。

想一想,我们如何来判断要到底部或者即将到底部了呢,有小伙伴说如果scrollTop如果大于等于虚拟元素的总高不就说明到底了吗,no scrollTop可以理解为虚拟元素顶部到可视区域顶部的距离,还差一个可视区域的高度,所以,scrollTop + clientHeight >= virtualHeight才说明到底了,此时走过来一个老伙伴问,那我怎么才能实现即将到底时就发送求情呢,问得好,比如我们想距离底部100px时就发送请求,我们只要再加100就好了呀,又又又来了个小东西问加载效果呢,问的也好,但是没有刚才好,答:只要在内容区最下方加一条内容用一个状态来控制它的显隐就好了呀。完整版如下:

<template>
  {{ `startindex:${startIndex}` }}&nbsp;&nbsp;&nbsp;&nbsp;{{
    `endIndex:${endIndex}`
  }}&nbsp;&nbsp;&nbsp;&nbsp;{{ `scrollTop:${scrollTop}` }}
  {{ currentPage }}
  <!-- 可视区域 -->
  <div class="virtua_main" @scroll="onScroll">
    <!-- 虚拟元素 -->
    <div class="occupy_pace" :style="{ height: virtualHeight }"></div>
    <!-- 内容区域 -->
    <div
      class="virtua_content"
      ref="virtuaContent"
      :style="{ height: contentHeight, '--row-height': rowHeight + 'px' }"
    >
      <p class="virtua_item" v-for="item in visibleItems" :key="item.id">
        {{ item.label }}
      </p>
      <p v-if="loading" class="virtua_item loading-indicator">加载中。。。</p>
    </div>
  </div>
</template>

<script setup>
import { ref } from "vue";
//模拟一千条数据
const allList = ref(
  Array.from({ length: 1000 }).map((_, index) => ({
    id: index,
    label: `Item ${index + 1}`,
  }))
);
//内容区域元素
const virtuaContent = ref(null);
//可视区域显示条数
const showSize = ref(10);
//每行高度
const rowHeight = ref(50);
//当前滚动高度
const scrollTop = ref(0);
//虚拟元素高度
const virtualHeight = computed(() => {
  return allList.value.length * rowHeight.value + "px";
});
//内容区高度
const contentHeight = computed(() => {
  return showSize.value * rowHeight.value + "px";
});
//截取展示内容数据开始的索引
const startIndex = computed(() => {
  //开始的索引要考虑边界问题,不能比0小,同时不能大于数据总长度减显示条数
  const index = Math.floor(scrollTop.value / rowHeight.value);
  const maxStartIndex = Math.max(0, allList.value.length - showSize.value);
  return Math.min(index, maxStartIndex);
});
//定义缓冲数据大小
const buffer = 3;
//截取展示内容数据结束的索引
const endIndex = computed(() => {
  //也有边界问题,不能大于数据总长度
  //每次多加载buffer条数据
  return Math.min(
    startIndex.value + showSize.value + buffer,
    allList.value.length
  );
});
//内容区域展示的数据,从所有数据中截取showSize条
const visibleItems = computed(() => {
  return allList.value.slice(startIndex.value, endIndex.value);
});
//记录当前页,模拟状态下没啥用,真实情况下要发送给后端一般还要带一个pageSize
const currentPage = ref(1);
//定义加载状态
const loading = ref(false);
//定义距离下边界多少像素时触发加载更多
const loadMoreThreshold = 100;
//模拟一个加载更多数据函数
const loadMoreData = () => {
  loading.value = true;
  setTimeout(() => {
    //模拟网络请求
    allList.value.push(
      ...Array.from({ length: 1000 }).map((_, index) => ({
        id: allList.value.length + index,
        label: `Item ${allList.value.length + index + 1}`,
      }))
    );
    currentPage.value++;
    loading.value = false;
    scrollTop.value -= 10;
  }, 200);
};
//滚动事件
const onScroll = (event) => {
  const target = event.target;
  //将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
  scrollTop.value = target.scrollTop;
  virtuaContent.value.style.transform = `translateY(${
    startIndex.value * rowHeight.value
  }px)`;
  //判断是否触底
  const isBoundary =
    target.scrollTop + target.clientHeight + loadMoreThreshold >=
    virtualHeight.value.replace("px", "");

  if (isBoundary && !loading.value) {
    loadMoreData();
  }
};
</script>

<style scoped>
.virtua_main {
  width: 300px;
  height: 500px;
  overflow-y: auto;
  position: relative;
  background: #e0e0e0;
  color: red;
}

.virtua_content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}

.virtua_item {
  height: var(--row-height);
  margin: 0;
  border: 1px solid #000;
  box-sizing: border-box;
}
</style>

四、结语

这个代码很基础,我们可以把它运用到业务场景中,封装一个组件,或者把计算逻辑抽离出hooks,还有可以改为动态高度的版本,并且滚动事件应该添加防抖。有些地方可以更为精简,但是为了一些好兄弟能看的更明白所以这样写了。 代码中还隐藏了一些bug,老伙子们如果发现了可以给我留言,因为第一次写这么长的文章,文中如果有些逻辑不严谨或者错误、还有可以有优化的地方,也欢迎留言指正一起交流。

Vue 3 defineProps 与 defineEmits 深度解析

还在为 Vue 组件间的类型安全头疼吗?每次传参都像在玩“猜猜我是谁”,运行时错误频出,调试起来让人抓狂?别担心,今天我要带你彻底掌握 Vue 3 中的 defineProps 和 defineEmits,这对 TypeScript 的完美搭档将彻底改变你的开发体验。

读完本文,你将获得一套完整的类型安全组件通信方案,从基础用法到高级技巧,再到实战中的最佳实践。更重要的是,你会发现自己写出的代码更加健壮、可维护,再也不用担心那些烦人的类型错误了。

为什么需要 defineProps 和 defineEmits?

在 Vue 2 时代,我们在组件中定义 props 和 emits 时,类型检查往往不够完善。虽然可以用 PropTypes,但和 TypeScript 的配合总是差那么点意思。很多时候,我们只能在运行时才发现传递了错误类型的数据,这时候已经为时已晚。

想象一下这样的场景:你写了一个按钮组件,期望接收一个 size 属性,只能是 'small'、'medium' 或 'large' 中的一个。但在使用时,同事传了个 'big',TypeScript 编译时没报错,直到用户点击时才发现样式不对劲。这种问题在大型项目中尤其致命。

Vue 3 的 Composition API 与 TypeScript 的深度集成解决了这个问题。defineProps 和 defineEmits 这两个编译器宏,让组件的输入输出都有了完整的类型推导和检查。

defineProps:让组件输入类型安全

defineProps 用于定义组件的 props,它最大的优势就是与 TypeScript 的无缝集成。我们来看几种不同的用法。

基础用法很简单,但功能强大:

// 定义一个按钮组件
// 使用类型字面量定义 props
const props = defineProps<{
  size: 'small' | 'medium' | 'large'
  disabled?: boolean
  loading?: boolean
}>()

// 在模板中直接使用
// 现在有了完整的类型提示和检查

这种写法的好处是,当你使用这个组件时,TypeScript 会严格检查传入的 size 值。如果你试图传递 'big',编译器会立即报错,而不是等到运行时。

但有时候我们需要给 props 设置默认值,这时候可以这样写:

// 使用 withDefaults 辅助函数设置默认值
interface ButtonProps {
  size: 'small' | 'medium' | 'large'
  disabled?: boolean
  loading?: boolean
}

const props = withDefaults(defineProps<ButtonProps>(), {
  size: 'medium',
  disabled: false,
  loading: false
})

withDefaults 帮我们处理了默认值,同时保持了类型的完整性。这样即使父组件没有传递这些 props,子组件也能正常工作。

还有一种情况,我们需要混合使用运行时声明和类型声明:

// 运行时声明与类型声明结合
const props = defineProps({
  // 运行时声明
  label: {
    type: String,
    required: true
  },
  // 类型声明
  count: {
    type: Number,
    default: 0
  }
})

// 定义类型
interface Props {
  label: string
  count?: number
}

// 这种写法在某些复杂场景下很有用

这种混合写法在处理一些动态 prop 时特别有用,比如需要根据某些条件决定 prop 的类型。

defineEmits:组件输出的类型守卫

defineEmits 用于定义组件发出的事件,同样提供了完整的类型支持。这确保了我们在触发事件时传递正确的数据,也让使用者知道应该如何处理这些事件。

先看一个基础示例:

// 定义表单组件的事件
// 使用类型字面量定义 emits
const emit = defineEmits<{
  // submit 事件携带一个表单数据对象
  submit: [formData: FormData]
  // cancel 事件不携带数据
  cancel: []
  // input 事件携带字符串值
  input: [value: string]
}>()

// 在方法中触发事件
function handleSubmit() {
  const formData = gatherFormData()
  // TypeScript 会检查 formData 是否符合 FormData 类型
  emit('submit', formData)
}

function handleCancel() {
  // 不传递参数,符合类型定义
  emit('cancel')
}

这种写法的优势在于,当你在组件内调用 emit 时,TypeScript 会严格检查参数的类型和数量。如果你试图 emit('submit') 而不传递 formData,或者传递错误类型的参数,编译器会立即提醒你。

对于更复杂的场景,我们可以使用接口来定义事件:

// 使用接口定义事件类型
interface FormEvents {
  submit: (data: FormData) => void
  cancel: () => void
  validate: (isValid: boolean, errors: string[]) => void
}

const emit = defineEmits<FormEvents>()

// 在验证方法中触发复杂事件
function performValidation() {
  const isValid = validateForm()
  const errors = getValidationErrors()
  
  // TypeScript 确保我们传递正确的参数类型
  emit('validate', isValid, errors)
}

这种接口方式的定义让代码更加清晰,特别是当事件类型比较复杂时。你可以把所有的事件定义放在一个地方,便于维护和理解。

实战技巧:高级用法与最佳实践

在实际项目中,我们经常会遇到一些复杂场景,这时候就需要一些高级技巧来应对。

一个常见的需求是,我们需要基于已有的 props 类型来定义事件。比如在一个可搜索的表格组件中:

// 定义表格组件的 props 和 emits
interface TableProps {
  data: any[]
  columns: Column[]
  searchable?: boolean
  pagination?: boolean
}

const props = defineProps<TableProps>()

// 事件定义基于 props 的某些特性
const emit = defineEmits<{
  // 只有当 searchable 为 true 时才会有 search 事件
  search: [query: string]
  // 只有当 pagination 为 true 时才会有 pageChange 事件
  pageChange: [page: number]
  // 始终存在的选择事件
  rowSelect: [row: any]
}>()

// 在搜索方法中条件性触发事件
function handleSearch(query: string) {
  if (props.searchable) {
    // TypeScript 知道这个事件是有效的
    emit('search', query)
  }
}

另一个有用的技巧是泛型组件的定义。当我们想要创建可重用的通用组件时:

// 定义一个通用的列表组件
interface ListProps<T> {
  items: T[]
  keyField: keyof T
  renderItem?: (item: T) => any
}

// 使用泛型定义 props
function defineListProps<T>() {
  return defineProps<ListProps<T>>()
}

// 在具体组件中使用
interface User {
  id: number
  name: string
  email: string
}

// 为 User 类型特化组件
const props = defineListProps<User>()

这种泛型组件的方式在组件库开发中特别有用,它提供了极大的灵活性,同时保持了类型安全。

在处理异步操作时,我们通常需要定义加载状态和错误处理:

// 异步操作组件的完整类型定义
interface AsyncProps {
  data?: any
  loading?: boolean
  error?: string | null
}

interface AsyncEmits {
  retry: []
  reload: [force?: boolean]
  success: [data: any]
}

const props = defineProps<AsyncProps>()
const emit = defineEmits<AsyncEmits>()

// 在异步操作完成时触发事件
async function fetchData() {
  try {
    const result = await api.fetch()
    emit('success', result)
  } catch (error) {
    // 错误处理
  }
}

常见陷阱与解决方案

虽然 defineProps 和 defineEmits 很强大,但在使用过程中还是有一些需要注意的地方。

一个常见的错误是试图在运行时访问类型信息:

// 错误的做法:试图在运行时使用类型
const props = defineProps<{
  count: number
}>()

// 这在运行时是 undefined,因为类型信息在编译时就被移除了
console.log(props.count.type) // undefined

// 正确的做法:使用运行时声明
const props = defineProps({
  count: {
    type: Number,
    required: true
  }
})

另一个陷阱是关于可选参数的处理:

// 定义带有可选参数的事件
const emit = defineEmits<{
  // 第二个参数是可选的
  search: [query: string, options?: SearchOptions]
}>()

// 使用时要注意参数顺序
function handleSearch(query: string) {
  // 可以只传递必填参数
  emit('search', query)
}

function handleAdvancedSearch(query: string, options: SearchOptions) {
  // 也可以传递所有参数
  emit('search', query, options)
}

在处理复杂的嵌套对象时,类型定义可能会变得冗长:

// 使用类型别名简化复杂类型
type UserProfile = {
  personal: {
    name: string
    age: number
  }
  preferences: {
    theme: 'light' | 'dark'
    language: string
  }
}

const props = defineProps<{
  profile: UserProfile
}>()

// 这样既保持了类型安全,又让代码更清晰

与其它 Composition API 的配合

defineProps 和 defineEmits 可以很好地与 Vue 3 的其它 Composition API 配合使用,创造出强大的组合逻辑。

比如与 provide/inject 的配合:

// 父组件提供数据
const props = defineProps<{
  theme: 'light' | 'dark'
  locale: string
}>()

// 基于 props 提供全局配置
provide('appConfig', {
  theme: props.theme,
  locale: props.locale
})

// 子组件注入并使用
const config = inject('appConfig')

与 watch 和 computed 的配合:

const props = defineProps<{
  items: any[]
  filter: string
}>()

const emit = defineEmits<{
  filtered: [results: any[]]
}>()

// 监听 props 变化并触发事件
watch(() => props.filter, (newFilter) => {
  const filtered = filterItems(props.items, newFilter)
  emit('filtered', filtered)
})

// 基于 props 计算衍生数据
const sortedItems = computed(() => {
  return props.items.sort(sortFunction)
})

性能优化与最佳实践

虽然类型安全很重要,但我们也要注意性能影响。以下是一些优化建议:

对于大型对象,考虑使用浅层响应式:

const props = defineProps<{
  // 对于大型配置对象,使用 shallowRef 避免不必要的响应式开销
  config: AppConfig
  // 对于频繁变化的数据,保持深度响应式
  items: any[]
}>()

合理使用 PropType 进行复杂类型验证:

import type { PropType } from 'vue'

const props = defineProps({
  // 使用 PropType 进行运行时类型验证
  complexData: {
    type: Object as PropType<ComplexData>,
    required: true,
    validator: (value: ComplexData) => {
      return validateComplexData(value)
    }
  }
})

总结

defineProps 和 defineEmits 是 Vue 3 与 TypeScript 完美结合的代表作。它们不仅提供了编译时的类型安全,还大大提升了开发体验。通过本文的学习,你应该能够在组件中正确定义类型安全的 props 和 emits,充分利用 TypeScript 的类型推导能力,处理各种复杂场景下的类型需求,避免常见的陷阱和错误。

前端日常工作开发技巧汇总

一、JS篇

1. structuredClone 深拷贝

JavaScript 内置了一个 structuredClone() 的方法, 此方法提供了一种简单有效的方法来深度克隆对象,支持复杂数据类型,包括 DateRegExpMapSetArrayBufferBlobFile 等。浏览器底层实现,通常比手动递归或 JSON 方法更高效。

兼容性 image.png

2. 函数式编程

ES14 更新了许多数组方法或者为原有的数组方法增加不会带来突变(without mutation) 的互补方法。意味着它们会基于原数组创建新的数组,而不是直接修改原数组。

新增的互补方法有

  • Array.sort() -> Array.toSorted()
  • Array.splice() -> Array.toSpliced()
  • Array.reverse() -> Array.toReversed()

新增的新数组方法有:Array.with()Array.findLast()Array.findLastIndex()

  • Array.with()
    返回一个新数组,将原数组中指定索引 index 的元素替换为 value不修改原数组

语法
index:要替换的元素的索引(可以是负数,表示从末尾开始计数)。
value:替换后的新值

const newArray = array.with(index, value)

const arr = [1, 2, 3, 4];
const newArr = arr.with(1, "hello"); // 替换索引 1 的元素 

console.log(arr);    // [1, 2, 3, 4](原数组不变)
console.log(newArr); // [1, "hello", 3, 4](新数组)

// 支持负数索引(从末尾开始)
const newArr2 = arr.with(-2, "world"); // 替换倒数第 2 个元素
console.log(newArr2); // [1, 2, "world", 4]
  • Array.findLast()
    从数组末尾向前查找第一个满足 callback 条件的元素,并返回该元素。如果未找到,返回 undefined

  • Array.findLastIndex()
    从数组末尾向前查找第一个满足 callback 条件的元素,并返回其索引。如果未找到,返回 -1

3. 惰性函数

JavaScript 中的 惰性函数(Lazy Function) 是一种优化技术,其核心思想是:函数在第一次调用时执行一些初始化或判断逻辑,并在执行后将自身重定义为一个更高效或更简单的版本,后续调用就直接使用这个新版本,避免重复开销

普通写法

function copyToClipboard(text) {
    // 优先使用Clipboard API
    if (navigator.clipboard) {
      return navigator.clipboard
        .writeText(text)
        .then(() => {
          Message.success('复制成功')
          return true
        })
        .catch((err) => {
          console.error('使用Clipboard API复制失败: ', err)
          // 如果Clipboard API失败,尝试使用降级方案
          return copyUsingExecCommand(text)
        })
    } else {
      // 如果不支持Clipboard API,直接使用降级方案
      return copyUsingExecCommand(text)
    }
}

惰性写法

function copyToClipboard(text) {
  // 第一次调用时进行能力检测,并重定义自身
  if (navigator.clipboard) {
    // 支持 Clipboard API
    copyToClipboard = function (text) {
      return navigator.clipboard
        .writeText(text)
        .then(() => {
          console.log('文本已成功复制到剪贴板');
          return true;
        })
        .catch((err) => {
          console.error('Clipboard API 复制失败:', err);
          return false;
        });
    };
  } else {
    // 不支持 Clipboard API,使用 execCommand 降级方案
    copyToClipboard = function (text) {
      return copyUsingExecCommand(text);
    };
  }

  // 执行第一次调用
  return copyToClipboard(text);
}

二、CSS篇

1. 滚动吸附

<template>
  <div>
    <div class="container">
      <div class="item">1</div>
      <div class="item">2</div>
      <div class="item">3</div>
    </div>
  </div>
</template>

<script setup name="Snap"></script>

<style lang="scss" scoped>
.container {
  width: 100%;
  height: 300px;
  display: flex;
  overflow-x: scroll;
  // 吸附效果 mandatory: 必须吸附  proximity: 靠近时吸附
  scroll-snap-type: x mandatory;
  .item {
    flex-shrink: 0;
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 30px;
    color: #fff;
    background-color: #ccc;
    // 吸附位置
    scroll-snap-align: start;
    scroll-snap-stop: always;
    &:nth-child(1) {
      background-color: #f56c6c;
    }
    &:nth-child(2) {
      background-color: #67c23a;
    }
    &:nth-child(3) {
      background-color: #409eff;
    }
  }
}
</style>
兼容性较高

image.png

2. 字体自适应容器大小

<template>
  <div>
    <div class="container">
      <p>字体自适应容器大小</p>
    </div>
  </div>
</template>

<script setup name=""></script>

<style lang="scss" scoped>
.container {
  width: 500px;
  height: 300px;
  padding: 15px;
  resize: both;
  overflow: hidden;
  background-color: aquamarine;
  container-type: inline-size; // 启用容器查询 size:基于宽高 / inline-size 基于宽度 / normal 不启用
  p {
    font-size: 5cqh;
  }
}
</style>
兼容性还行

image.png

3. 选择器

  • 选择器特定性(通常叫做选择器权重)

当希望某个css属性优先级高于其他属性值时,尽量不要使用!important!important会打破这些固有的级联规则,使得样式的应用变得不那么可预测。这可能会导致样式表难以维护和理解,尤其是在大型项目中。增加调试难度,也限制了样式的灵活性。

替代方案: 通过编写更具体(或更精确)的选择器来覆盖样式,或者叠加选择器,比如:222

.el-button.el-button {
    color: red;
}
  • 新型选择器

:has()选择器: 根据一个元素是否包含某些特定的后代元素,或者其后的同级元素是否满足某些条件,来选中该元素本身。这实现了“向下”观察的能力。

示例1: 选择包含 <img><div>

<div>这个 div 没有图片,不会被选中</div>
<div>
    <img src="example.jpg" alt="示例图片">
    这个 div 包含图片,会被红色边框包围
</div>
/* 选择包含 <img> 的 div */ 
div:has(img) {
    border: 3px solid red; padding: 10px;
}

示例2: 选择紧跟着 <p><h2>

<h2>这个 h2 后面没有紧跟着 p,不会被选中</h2>
<div>分隔内容</div>
<h2>这个 h2 后面紧跟着 p</h2>
<p>这个 p 是 h2 的紧邻兄弟元素,因此 h2 会变成蓝色斜体</p>
/* 选择后面紧跟着 <p> 的 h2 */
h2:has(+ p) {
    color: blue; font-style: italic;
}

兼容性 image.png

:is()选择器: 它接受一个逗号分隔的选择器列表作为参数,并匹配其中任意一个选择器。这有助于减少冗余代码,提高可读性。:is() 的权重等于它括号里所有选择器中权重最高的那个。

示例:

<header>
    <h1>这是 header 的 h1(紫色)</h1>
</header>
<main>
    <h1>这是 main 的 h1(紫色)</h1>
</main>
<footer>
    <h1>这是 footer 的 h1(紫色)</h1>
</footer>
<section>
    <h1>这个 h1 不在 :is() 范围内,保持默认颜色</h1>
</section>
/* 统一设置 header、main、footer 下的 h1 样式 */
:is(header, main, footer) h1 {
    color: purple; font-family: Arial, sans-serif;
}

兼容性 image.png

:where()选择器: 与 :is() 类似,但权重永远为 0,适合默认样式。

兼容性 image.png

三、VUE篇

1. v-memo

Vue 3 提供的性能优化指令,其作用是通过缓存模板子树的渲染结果,仅在依赖项变化时重新渲染,从而减少不必要的虚拟 DOM 计算和更新操作。

v-memo 接收一个依赖数组,只有当数组中的值发生变化时才会重新渲染。

示例:优化大型列表渲染,避免全量更新。 当 item.id 或 item.status 变化时,仅更新对应项;其他项复用缓存结果

<div v-for="item in list" :key="item.id" v-memo="[item.id, item.status]">
  {{ item.content }}
  <StatusBadge :status="item.status" />
</div>

2. watch —— 副作用和深度监听

3. customRef ——— 自定义响应式依赖追踪

4. 组件的二次封装

四、Chrome浏览器调试技巧

1. $0

2. 模拟聚焦网页

3. 重放XHR

五、VSCode编辑器插件分享

1. i18n Ally

  • 代码内直接预览翻译文本
  • 快速生成初始翻译
  • 一键跳转至对应翻译条目
  • 集中管理
  "i18n-ally.localesPaths": ["./src/i18n/lang/locales"], // 翻译文件夹路径
  "i18n-ally.pathMatcher": "{locale}/**/{namespace}.json", // 翻译目标文件路径匹配
  "i18n-ally.keystyle": "nested", // 翻译路径格式,
  "i18n-ally.sourceLanguage": "zh-CN", // 翻译源语言
  "i18n-ally.displayLanguage": "zh-CN", //显示语言, 这里也可以设置显示英文为en
  "i18n-ally.sortKeys": true, // 是否自动排序
  "i18n-ally.namespace": false, // 是否启用命名空间,一般在积攒多个待翻译文案时启用,可以自动编辑至对应文件中
  "i18n-ally.enabledParsers": ["ts", "js", "json"], // 翻译文件可允许的格式,默认json

2. koroFileHeader @4.9.2

用于生成文件头部注释和函数注释的插件

快捷键‌:

  • 头部注释:Ctrl+Win+I(Windows/Linux)或 Ctrl+Cmd+I(Mac)
  • 函数注释:Ctrl+Win+T(Windows/Linux)或 Ctrl+Cmd+T(Mac)
// 头部注释
"fileheader.customMade": {
"Author": "git config user.name && git config user.email", // 同时获取用户名与邮箱
"Date": "Do not edit", // 文件创建时间
"LastEditors": "git config user.name && git config user.email", // 文件最后编辑者 与Author字段一致
"LastEditTime": "Do not edit", // 文件最后编辑时间
"FilePath": "Do not edit", // 文件在项目中的相对路径 自动更新
"Description": "" // 文件描述
},

Vue2实现语音报警

Vue2 实现消息语音报警功能 下面是一个完整的Vue2消息语音报警实现方案,包含多种语音播报方式和自定义配置。 1. 安装依赖 2. 语音报警组件 核心语音服务类 Vue2语音报警组件 3. 全局事

一种新HTML页面转换成 PDF 技术方案

背景

本文将深入讲解如何使用 snapdom 和 jsPDF 实现高质量的 HTML 转 PDF 功能,并通过一个完整的消息列表导出案例,带你掌握这套方案的核心技术。

为什么 HTML 转 PDF 如此重要?

在现代 Web 应用中,HTML 转 PDF 是一个非常常见的需求场景:

  1. 客服系统:导出聊天记录用于存档或投诉处理
  2. 电商平台:生成订单详情、发票等 PDF 文档
  3. 报表系统:将可视化图表和数据导出为 PDF 报告
  4. 在线文档:支持用户将网页内容离线保存
  5. 合同签署:生成合同 PDF 用于电子签名

然而,实现一个高质量的 HTML 转 PDF 功能并不简单。我们面临以下挑战:

挑战 描述
样式还原 CSS 样式、字体、渐变等能否完美呈现?
分页处理 长内容如何智能分页,避免内容被截断?
清晰度 导出的 PDF 是否足够清晰,尤其在打印时?
性能 大量内容(如 1000 条消息)能否快速导出?
兼容性 不同浏览器表现是否一致?

传统的 html2canvas + jsPDF 方案虽然能用,但在样式还原度截图质量上存在明显不足。

今天笔者介绍一套新解决方案:snapdom + jsPDF

snapdom 和 jsPDF 基础理论知识

snapdom 是什么?

SnapDOM 是一个现代化的 DOM 截图库,它的核心特点是:

DOM Element → Canvas/PNG/SVG

核心优势

  1. 高保真截图:完美还原 CSS 样式,包括 flexbox、grid、渐变、阴影等
  2. 多种输出格式:支持 Canvas、PNG、SVG 等多种格式
  3. 高清缩放:通过 scale 参数实现 2x/3x 高清截图
  4. 体积小巧:压缩后仅 ~20KB

基础用法

import { snapdom } from '@zumer/snapdom';

// 获取 DOM 元素
const element = document.querySelector('.my-element');

// 截图
const capture = await snapdom(element, {
  scale: 2,      // 2倍清晰度
  quality: 0.95  // PNG 质量
});

// 输出方式
const canvas = await capture.toCanvas();  // Canvas 元素
const imgEl = await capture.toPng();      // <img> 元素,src 为 data URL
const svgStr = await capture.toSvg();     // SVG 字符串

关键参数说明

参数 类型 默认值 说明
scale number 1 缩放倍数,2 表示 2 倍清晰度
quality number 0.92 图片质量,范围 0-1

更多详细内容请看snapdom.dev/官方文档

jsPDF 是什么?

jsPDF 是最流行的 JavaScript PDF 生成库,支持在浏览器端直接创建 PDF 文件。

核心特点

  1. 纯前端方案:无需服务端,浏览器直接生成
  2. 功能丰富:支持文本、图片、表格、链接等
  3. 多种尺寸:A4、Letter 等标准纸张格式
  4. 插件生态:支持 AutoTable 等扩展插件

基础用法

import { jsPDF } from 'jspdf';

// 创建 PDF 实例
const pdf = new jsPDF({
  orientation: 'portrait',  // 纵向
  unit: 'mm',               // 单位:毫米
  format: 'a4',             // A4 纸张
  compress: true            // 启用压缩
});

// 添加图片
pdf.addImage(
  imageDataUrl,  // Base64 图片数据
  'PNG',         // 图片格式
  10,            // X 坐标(mm)
  10,            // Y 坐标(mm)
  190,           // 宽度(mm)
  100            // 高度(mm)
);

// 添加新页面
pdf.addPage();

// 保存文件
pdf.save('output.pdf');

A4 尺寸常量

// A4 标准尺寸(单位:mm)
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;

// 页面边距
const MARGIN_MM = 10;

// 可用内容区域
const CONTENT_WIDTH_MM = 190;   // 210 - 10*2
const CONTENT_HEIGHT_MM = 277;  // 297 - 10*2

snapdom + jsPDF 组合的优势

image.png

案例讲述

笔者写一个IM产品中 MessageList 消息导出DEMO。接下来,我们通过一个完整的客服消息列表导出案例,讲解如何使用 snapdom + jsPDF 实现 HTML 转 PDF。

项目结构

src/
├── components/
│   ├── MessageList.tsx      # 消息列表组件
│   └── MessageList.css      # 消息列表样式
├── services/
│   └── messageExportService.ts  # PDF 导出服务(核心)
└── App.tsx

核心流程

整个导出过程分为 4 个步骤

image.png

Step 1:DOM 截图(snapdom)

第一步,使用 snapdom 将整个消息列表 DOM 转换为高清 PNG 图片。

// messageExportService.ts

import { snapdom } from '@zumer/snapdom';

// 图片质量配置
const IMAGE_QUALITY = 0.95;
const IMAGE_FORMAT = 'image/png' as const;

/**
 * 将 DOM 元素转换为图片
 */
export async function captureElementToImage(
  element: HTMLElement,
  quality: number = IMAGE_QUALITY
): Promise<string> {
  console.log('开始截图...');

  // 保存原始样式
  const originalOverflow = element.style.overflow;
  const originalHeight = element.style.height;
  const originalMaxHeight = element.style.maxHeight;

  // 临时设置样式,确保完整截图
  element.style.overflow = 'visible';
  element.style.height = 'auto';
  element.style.maxHeight = 'none';

  try {
    // 核心:使用 snapdom 进行截图
    const capture = await snapdom(element, {
      scale: 2,        // 2倍清晰度
      quality: quality
    });

    // 优先使用 toPng()
    const imgElement = await capture.toPng();
    const dataUrl = imgElement.src;

    // 验证数据有效性
    if (!dataUrl || dataUrl.length < 100) {
      console.log('toPng 返回无效,尝试 toCanvas...');
      const canvas = await capture.toCanvas();
      return canvas.toDataURL(IMAGE_FORMAT, quality);
    }

    console.log('截图成功,大小:', (dataUrl.length / 1024).toFixed(2), 'KB');
    return dataUrl;

  } finally {
    // 恢复原始样式
    element.style.overflow = originalOverflow;
    element.style.height = originalHeight;
    element.style.maxHeight = originalMaxHeight;
  }
}

关键点解析

  1. 临时修改样式:将 overflowheightmaxHeight 临时设置为可见状态,确保截取完整内容
  2. scale: 2:2 倍缩放提高清晰度,打印时效果更佳
  3. 降级处理toPng() 失败时自动回退到 toCanvas()
  4. 样式恢复:截图完成后恢复原始样式

Step 2:图片分页(Canvas)

长图片需要按照 A4 页面高度进行分割,这是最复杂的一步。

// 尺寸常量
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const PDF_MARGIN_MM = 10;
const PDF_CONTENT_WIDTH_MM = A4_WIDTH_MM - PDF_MARGIN_MM * 2;   // 190mm
const PDF_CONTENT_HEIGHT_MM = A4_HEIGHT_MM - PDF_MARGIN_MM * 2; // 277mm

// 1mm = 3.7795275590551 像素(96 DPI)
const MM_TO_PX = 3.7795275590551;

// 分页后的图片数据
interface PageImageData {
  dataUrl: string;
  width: number;
  height: number;
}

/**
 * 将长图片分割成多个 A4 页面
 */
export async function splitImageIntoPages(
  imageDataUrl: string
): Promise<PageImageData[]> {

  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = 'anonymous';

    img.onload = () => {
      const pages: PageImageData[] = [];
      const originalWidth = img.width;
      const originalHeight = img.height;

      // 将 A4 内容区域转换为像素(考虑 scale=2)
      const pageContentHeightPx = Math.floor(
        PDF_CONTENT_HEIGHT_MM * MM_TO_PX * 2  // scale=2
      );
      const pageContentWidthPx = Math.floor(
        PDF_CONTENT_WIDTH_MM * MM_TO_PX * 2
      );

      // 计算缩放比例(图片宽度适配页面宽度)
      const widthScale = pageContentWidthPx / originalWidth;
      const scaledHeight = originalHeight * widthScale;

      // 计算总页数
      const totalPages = Math.ceil(scaledHeight / pageContentHeightPx);

      console.log(`原始尺寸: ${originalWidth}x${originalHeight}px`);
      console.log(`缩放后高度: ${scaledHeight}px, 总页数: ${totalPages}`);

      // 逐页裁剪
      for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
        const startY = pageIndex * pageContentHeightPx;
        const endY = Math.min(startY + pageContentHeightPx, scaledHeight);
        const currentPageHeight = Math.floor(endY - startY);

        // 计算源图片对应的区域
        const sourceStartY = startY / widthScale;
        const sourceHeight = currentPageHeight / widthScale;

        // 创建新 Canvas
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d')!;

        canvas.width = pageContentWidthPx;
        canvas.height = currentPageHeight;

        // 高质量渲染
        ctx.imageSmoothingEnabled = true;
        ctx.imageSmoothingQuality = 'high';

        // 绘制当前页内容
        ctx.drawImage(
          img,
          0, sourceStartY,           // 源图片起始位置
          originalWidth, sourceHeight, // 源图片尺寸
          0, 0,                        // 目标起始位置
          pageContentWidthPx, currentPageHeight // 目标尺寸
        );

        // 转换为 data URL
        const pageDataUrl = canvas.toDataURL(IMAGE_FORMAT, IMAGE_QUALITY);

        pages.push({
          dataUrl: pageDataUrl,
          width: pageContentWidthPx,
          height: currentPageHeight
        });

        console.log(`第 ${pageIndex + 1}/${totalPages} 页处理完成`);
      }

      resolve(pages);
    };

    img.onerror = () => reject(new Error('图片加载失败'));
    img.src = imageDataUrl;
  });
}

分页算法图解

原始长图 (假设 5000px 高)
┌───────────────────┐
│                   │ ─┐
│      Page 1       │  │ 1046px (277mm × 3.78 × 2)
│                   │ ─┘
├───────────────────┤
│                   │ ─┐
│      Page 2       │  │ 1046px
│                   │ ─┘
├───────────────────┤
│                   │ ─┐
│      Page 3       │  │ 1046px
│                   │ ─┘
├───────────────────┤
│                   │ ─┐
│      Page 4       │  │ 1046px
│                   │ ─┘
├───────────────────┤
│      Page 5       │ ── 剩余 816px
│                   │
└───────────────────┘

Step 3:创建 PDF(jsPDF)

将分页后的图片逐一添加到 PDF 中。

import { jsPDF } from 'jspdf';

/**
 * 从分页图片创建 PDF
 */
export function createPdfFromPages(pages: PageImageData[]): jsPDF {
  const pdf = new jsPDF({
    orientation: 'portrait',
    unit: 'mm',
    format: 'a4',
    compress: true  // 启用压缩,减小文件体积
  });

  if (pages.length === 0) {
    throw new Error('没有可添加的页面');
  }

  pages.forEach((page, index) => {
    // 第一页直接用,后续需要 addPage
    if (index > 0) {
      pdf.addPage();
    }

    // 像素转毫米(考虑 scale=2)
    const scaleFactor = 2;
    const pageHeightMm = page.height / MM_TO_PX / scaleFactor;

    // 图片适配内容区域宽度
    const finalWidth = PDF_CONTENT_WIDTH_MM;  // 190mm
    const finalHeight = pageHeightMm;

    // 位置:左上角对齐,保留 10mm 边距
    const x = PDF_MARGIN_MM;
    const y = PDF_MARGIN_MM;

    console.log(`添加第 ${index + 1} 页: ${finalWidth}x${finalHeight.toFixed(2)}mm`);

    // 添加图片到 PDF
    pdf.addImage(page.dataUrl, 'PNG', x, y, finalWidth, finalHeight);
  });

  return pdf;
}

Step 4:主导出函数

将以上步骤串联起来,提供统一的导出接口。

interface ExportConfig {
  targetSelector: string;   // CSS 选择器
  filename?: string;        // 文件名
  quality?: number;         // 图片质量
}

/**
 * 主导出函数
 */
export async function exportMessagesToPdf(config: ExportConfig): Promise<void> {
  const {
    targetSelector,
    filename = 'messages.pdf',
    quality = IMAGE_QUALITY
  } = config;

  console.log('=== 开始导出 PDF ===');

  // 1. 获取目标元素
  const element = document.querySelector(targetSelector) as HTMLElement;
  if (!element) {
    throw new Error(`元素未找到: ${targetSelector}`);
  }

  console.log('元素尺寸:', {
    width: element.offsetWidth,
    height: element.scrollHeight
  });

  // 2. DOM 截图
  const imageDataUrl = await captureElementToImage(element, quality);
  console.log('截图完成,大小:', (imageDataUrl.length / 1024).toFixed(2), 'KB');

  // 3. 图片分页
  const pages = await splitImageIntoPages(imageDataUrl);
  console.log(`分页完成,共 ${pages.length} 页`);

  // 4. 创建 PDF
  const pdf = createPdfFromPages(pages);

  // 5. 保存文件
  pdf.save(filename);
  console.log('=== 导出完成 ===');
}

在组件中使用

// MessageList.tsx

import { exportMessagesToPdf } from '../services/messageExportService';

const MessageList: React.FC = () => {
  const messageListRef = useRef<HTMLDivElement>(null);
  const [isExporting, setIsExporting] = useState(false);

  const handleExportToPdf = useCallback(async () => {
    setIsExporting(true);

    try {
      // 生成带时间戳的文件名
      const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
      const filename = `messages-${timestamp}.pdf`;

      await exportMessagesToPdf({
        targetSelector: '.message-list-container',
        filename,
        quality: 0.95
      });

    } catch (error) {
      console.error('导出失败:', error);
      alert('导出失败,请重试');
    } finally {
      setIsExporting(false);
    }
  }, []);

  return (
    <div className="message-list-container" ref={messageListRef}>
      <div className="message-list-header">
        <h2>消息记录</h2>
        <button
          className="export-button"
          onClick={handleExportToPdf}
          disabled={isExporting}
        >
          {isExporting ? '导出中...' : '导出 PDF'}
        </button>
      </div>

      <div className="message-list">
        {messages.map(message => (
          <MessageItem key={message.id} message={message} />
        ))}
      </div>
    </div>
  );
};

完整效果

运行项目后,点击「导出 PDF」按钮:

  1. 控制台显示详细的导出日志
  2. 自动计算页数并分页
  3. 生成高清 PDF 文件并自动下载
=== 开始导出 PDF ===
目标选择器: .message-list-container
元素尺寸: { width: 600, height: 8500 }
开始截图...
截图完成,大小: 2847.65 KB
分页完成,共 8 页
添加第 1 页: 190x277.00mm
添加第 2 页: 190x277.00mm
...
添加第 8 页: 190x156.32mm
=== 导出完成 ===

SnapDOM VS html2canvas

为什么选择 SnapDOM 而不是更流行的 html2canvas?让我们来对比一下:

详细对比表

对比维度 SnapDOM html2canvas
样式还原 ★★★★★ 接近完美 ★★★☆☆ 部分样式丢失
Flexbox/Grid ✅ 完美支持 ⚠️ 部分问题
渐变背景 ✅ 完美支持 ⚠️ 可能失真
阴影效果 ✅ 完美支持 ⚠️ 部分丢失
自定义字体 ✅ 支持 ⚠️ 需要额外处理
SVG 支持 ✅ 原生支持 ⚠️ 有限支持
输出格式 PNG/Canvas/SVG Canvas/PNG
包大小 ~20KB ~60KB
维护状态 活跃更新 较少更新
API 设计 现代 Promise 回调 + Promise

代码对比

html2canvas 方式:

import html2canvas from 'html2canvas';

// 需要处理各种兼容性问题
const canvas = await html2canvas(element, {
  scale: 2,
  useCORS: true,
  logging: false,
  allowTaint: true,
  foreignObjectRendering: true,  // 可能不生效
  // 还需要处理字体、SVG 等问题...
});

const dataUrl = canvas.toDataURL('image/png');

SnapDOM 方式:

import { snapdom } from '@zumer/snapdom';

// 简洁的 API,无需额外配置
const capture = await snapdom(element, {
  scale: 2,
  quality: 0.95
});

const dataUrl = (await capture.toPng()).src;

什么时候选择 html2canvas?

虽然 SnapDOM 在大多数场景下更优秀,但 html2canvas 在以下情况可能更适合:

  1. 项目已在使用:迁移成本较高
  2. 简单场景:只需截取简单文本,无复杂样式
  3. 团队熟悉度:团队对 html2canvas 更熟悉

总结

核心要点回顾

  1. SnapDOM 提供高保真的 DOM 截图能力,通过 scale: 2 实现 2 倍清晰度
  2. jsPDF 是强大的 PDF 生成库,支持 A4 纸张、压缩等特性
  3. 分页算法 是整个方案的核心难点,需要精确计算像素与毫米的转换
  4. SnapDOM 相比 html2canvas 在样式还原度上有明显优势

进一步优化方向

优化点 说明
Web Worker 将分页计算放到 Worker 中,避免阻塞主线程
分段截图 超长内容分段截图,避免内存溢出
加载提示 添加进度条,提升用户体验
PDF 压缩 使用 pdf-lib 进一步压缩 PDF 体积
页眉页脚 添加页码、时间戳等信息

Ant Design Vue 日期选择器英文不变更中文问题

Ant Design Vue 日期选择器中英文混杂问题分析与解决

项目背景

  • 技术栈:Vue 3.5.24 + Ant Design Vue 4.2.6
  • 日期库:从 v3 起 Ant Design Vue 默认使用 dayjs

问题描述

在全局已经配置中文(ConfigProvider + dayjs.locale('zh-cn'))的情况下,DatePicker 组件仍出现“中英文混杂”:

  • “年”“今天”等字样为中文
  • 月份(Jan/Feb…)与星期(Mon/Tue…)依旧显示英文
  • 无论全局注入还是局部覆盖 locale 均无效 企业微信截图_1b148f4c-d467-42a8-a4b8-ae1c3b81d0eb.png

深层原因剖析

  1. dayjs 版本过旧
    早期 dayjszh-cn 语言包缺失 months/weekdays 的中文定义,或补丁未完全下发。

  2. 多版本 dayjs 共存
    pnpm 的去重策略可能导致锁文件里存在多个 dayjs 版本,入口文件设置的 dayjs.locale 未必作用于 Ant Design Vue 内部使用的实例。

  3. 执行顺序/Tree-shaking 问题
    Vite 的懒加载或 chunk 切分可能使 import 'dayjs/locale/zh-cn' 未及时执行;若没有紧跟 dayjs.locale('zh-cn'),组件渲染阶段仍使用默认英文。

  4. 语言包字段缺失
    旧版本 dayjszh-cn 语言包里 weekdaysShortmonthsShort 等字段为空,Antd 组件 fallback 为英文。

排查步骤(建议流程)

  1. 确认全局中文配置

    import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; dayjs.locale('zh-cn');

  2. 检查 dayjs 版本

    pnpm list dayjs pnpm why dayjs 关注是否存在多个版本或锁定在 1.11.0 之前。

  3. 查看本地语言包
    打开 node_modules/dayjs/locale/zh-cn.js,确认 monthsweekdays 等数组是否为中文。

解决方案

  • 结论:升级 dayjs 至 ≥ 1.11.19

  • 操作步骤: 企业微信截图_bf2e42f6-e949-4fb7-8c95-51029d1df296.png pnpm add dayjs@1.11.19 -w

    import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; dayjs.locale('zh-cn');

    • 重启 dev server 并清理缓存,确认 DatePicker 面板的月份、星期、按钮均已中文化。

可选补充

  • Ant Design Vue 的日期国际化完全依赖 dayjs,语言异常优先排查 dayjs 版本和语言包
  • Monorepo/多包环境需确保 dayjs 版本统一,避免多版本导致的 locale 失效
  • import 'dayjs/locale/zh-cn' 后务必紧接 dayjs.locale('zh-cn'),并确保在入口同步执行

参考资料

❌