阅读视图

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

每日一题-可以被 K 整除连通块的最大数目🔴

给你一棵 n 个节点的无向树,节点编号为 0 到 n - 1 。给你整数 n 和一个长度为 n - 1 的二维整数数组 edges ,其中 edges[i] = [ai, bi] 表示树中节点 ai 和 bi 有一条边。

同时给你一个下标从 0 开始长度为 n 的整数数组 values ,其中 values[i] 是第 i 个节点的  。再给你一个整数 k 。

你可以从树中删除一些边,也可以一条边也不删,得到若干连通块。一个 连通块的值 定义为连通块中所有节点值之和。如果所有连通块的值都可以被 k 整除,那么我们说这是一个 合法分割 。

请你返回所有合法分割中,连通块数目的最大值 。

 

示例 1:

输入:n = 5, edges = [[0,2],[1,2],[1,3],[2,4]], values = [1,8,1,4,4], k = 6
输出:2
解释:我们删除节点 1 和 2 之间的边。这是一个合法分割,因为:
- 节点 1 和 3 所在连通块的值为 values[1] + values[3] = 12 。
- 节点 0 ,2 和 4 所在连通块的值为 values[0] + values[2] + values[4] = 6 。
最多可以得到 2 个连通块的合法分割。

示例 2:

输入:n = 7, edges = [[0,1],[0,2],[1,3],[1,4],[2,5],[2,6]], values = [3,0,6,1,5,2,1], k = 3
输出:3
解释:我们删除节点 0 和 2 ,以及节点 0 和 1 之间的边。这是一个合法分割,因为:
- 节点 0 的连通块的值为 values[0] = 3 。
- 节点 2 ,5 和 6 所在连通块的值为 values[2] + values[5] + values[6] = 9 。
- 节点 1 ,3 和 4 的连通块的值为 values[1] + values[3] + values[4] = 6 。
最多可以得到 3 个连通块的合法分割。

 

提示:

  • 1 <= n <= 3 * 104
  • edges.length == n - 1
  • edges[i].length == 2
  • 0 <= ai, bi < n
  • values.length == n
  • 0 <= values[i] <= 109
  • 1 <= k <= 109
  • values 之和可以被 k 整除。
  • 输入保证 edges 是一棵无向树。

在 React + React Router v7 SSR 项目里做多端适配,我踩的两个坑

前言

最近帮人维护一个 SSRReact 项目,需要在同一套代码里适配 PC、手机和平板。页面里大量逻辑是基于 deviceTypedesktop | tablet | mobile)来做布局和交互差异的。

刚开始看上去只是“判断一下宽度 + 写点媒体查询”的小需求,但在 SSR + iOS 真机 这两个维度叠加之后,踩了不少兼容性的坑,特别是:

  1. SSR 环境下拿不到 window,导致页面在客户端首次渲染时闪烁;
  2. iOS 真机上 window.innerWidth 获取存在延迟,导致偶发性识别错误。

这篇文章主要记录一下这两个问题的具体表现、解决思路,以及最终抽出来的一个 useDeviceType Hook

如果文章对你有帮助的话,记得一键三连哟。有问题和疑惑的话也可以在评论区留言。我会第一时间回复大家,如果觉得我的文章哪里有知识点错误的话,也恳请能够告知,把错的东西理解成对的,无论在什么行业,都是致命的。

问题背景

因为是 SSR 项目,服务端初始会把页面 HTML 渲染出来,再由客户端进行 hydration
同时,我们希望做到:

  • PC 端:展示完整布局;
  • 手机端:展示精简布局,组件层级也有差异;
  • 平板端:布局/交互介于两者之间。

项目里有大量类似下面这样的逻辑:

const { deviceType } = useDeviceType();

return (
  <>
    {deviceType === "desktop" && <DesktopLayout />}
    {deviceType === "tablet" && <TabletLayout />}
    {deviceType === "mobile" && <MobileLayout />}
  </>
);

这意味着 初始渲染阶段的设备类型判断非常关键,一旦前后不一致,就会导致闪烁、Hydration Mismatch 等问题。

SSR 环境中拿不到 window 导致页面闪烁

问题表现

  • deviceType 默认假定为 "desktop"
  • 用户在 手机 上打开页面时:
    • SSR 阶段:服务端根据默认值 "desktop" 渲染;
    • 客户端 hydration 后:useEffect / useLayoutEffect 中根据 window.innerWidth 判断出其实是 "mobile",然后状态更新;
  • 在这个切换的瞬间,布局会从 desktop 版“瞬间”跳为 mobile 版,非常明显的闪烁。

如果你用的是 useLayoutEffect,在 SSR 环境下还会看到:

Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output...

问题根源

  • SSR 环境没有 window,无法用宽度判断设备;
  • 初始渲染的 HTMLdesktop 版,客户端 hydration 时才“意识到”这是手机;
  • 由于初始 state 和实际设备不匹配,导致:
    • UI 闪烁;
    • 以及 hydration mismatch 警告。

解决思路:同构版的 useLayoutEffect

  • 在浏览器端:使用 useLayoutEffect,在浏览器绘制前完成 DOM 调整,尽量减少闪烁;
  • **在 SSR 端:不要使用 useLayoutEffect,避免警告,退化为普通的 useEffect

实现方式很简单,封装一个Hook

import { useEffect, useLayoutEffect } from "react";

const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

然后在需要做首屏布局调整的地方,统一用 useIsomorphicLayoutEffect 替换 useLayoutEffect

再配合一个关键点:初始值保持一致

为了避免 hydration mismatch,初始 state 必须在 SSR 和客户端首次渲染时保持一致,所以可以这么写:

const [deviceInfo, setDeviceInfo] = useState<DeviceInfo>({
  deviceType: "desktop",  // 服务端 & 客户端初始都认为是 desktop
  isMinWidth: false,
  currentWidth: 1024,
});

挂载后,再通过 window.innerWidth + UA 去纠正这个默认值。

iOS 真机上 innerWidth 延迟导致设备误判

这个坑比第一个更隐蔽一些。

问题表现

在某些 iOS 版本的 Safari / PWA 中,出现了这样的情况(我的是 iOS26):

  1. 页面刚加载时,window.innerWidth 返回的值偏大(类似平板或桌面宽度);
  2. 在初次识别 deviceType 时,逻辑会认为是 "tablet""desktop"
  3. 用户一滚动/点击,触发重排(reflow)后,innerWidth 才变成实际的手机宽度;
  4. 于是 resize 事件触发,又重新判了一次设备类型,这次才变成 "mobile"
  5. 最终结果:页面偶发性地先渲染成平板布局,体验非常差。

解决思路:UA 为主,宽度为辅

单纯依赖 window.innerWidthiOS 上不够稳。
更稳妥的方式是:

  1. 使用 UA 作为第一判断依据
    利用 ua-parser-js 获取设备类型,如果 UA 明确告诉你是 mobile,那就直接按 mobile 处理,明确是 tablet,就直接当平板。
import { UAParser } from "ua-parser-js";

const parser = new UAParser(navigator.userAgent);
const result = parser.getResult();
const uaDeviceType = result.device.type; // 'mobile' | 'tablet' | 'console' | 'smarttv' | 'wearable' | undefined
  1. 针对 iPad Propad 设备做特殊识别
    iPad Pro+Air UA 会伪装成 Mac,因此需要结合 OS + 能否触摸判断:
const isIPadPro = result.os.name === "Mac OS" && navigator.maxTouchPoints > 1;

在这种情况下,即使 UA 看起来像 Mac 电脑,也要当平板处理。

  1. UA 不明确时,再用宽度兜底
    比如 UA 显示为桌面,或者 UA 不可靠时,可以退回到宽度判断:
if (width < 768) {
  return "mobile";
} else if (width >= 768 && width < 1280) {
  return "tablet";
}
return "desktop";
  1. 监听 resize 做矫正
    即使初次判断不完美,也可以在后续 resize(包括横竖屏切换、窗口变化等)时重新计算设备类型进行纠正。

最终实现:useDeviceType Hook

下面是一个最终整合后的 Hook,实现了:

  • SSR 环境兼容(useIsomorphicLayoutEffect);
  • UA + 宽度组合判断设备类型;
  • 动态设置 viewport / minWidth / 安全区域样式;
  • 监听 resize 自动更新。

设备类型判断逻辑

// hooks/useDeviceType.ts
import { useState, useEffect, useLayoutEffect } from "react";
import { UAParser } from "ua-parser-js";
import type {
  DeviceInfo,
  DeviceType,
  UseDeviceTypeOptions,
} from "~/types/GameDataType";

// SSR 下避免 useLayoutEffect 警告
const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

// UA + 宽度综合判断设备类型
const getDeviceType = (width: number): DeviceType => {
  const parser = new UAParser(navigator.userAgent);
  const result = parser.getResult();
  const uaDeviceType = result.device.type;

  // iPad Pro 桌面模式的特殊处理:Mac OS + 支持触摸
  const isIPadPro = result.os.name === "Mac OS" && navigator.maxTouchPoints > 1;

  // 1. UA 明确识别为 mobile
  if (uaDeviceType === "mobile") {
    return "mobile";
  }

  // 2. UA 明确识别为 tablet(包括 iPad Pro)
  if (uaDeviceType === "tablet" || isIPadPro) {
    return "tablet";
  }

  // 3. 其它情况用宽度兜底
  if (width < 768) {
    return "mobile";
  } else if (width >= 768 && width < 1280) {
    return "tablet";
  }

  return "desktop";
};

Hook 主体

export const useDeviceType = (
  options: UseDeviceTypeOptions = {}
): DeviceInfo => {
  const {
    minWidth = 240,
    preventZoom = true,
    enableSafeAreas = true,
  } = options;

  // SSR & 客户端初始保持一致,避免 Hydration Mismatch
  const [deviceInfo, setDeviceInfo] = useState<DeviceInfo>({
    deviceType: "desktop",
    isMinWidth: false,
    currentWidth: 1024,
  });

  useIsomorphicLayoutEffect(() => {
    const checkDevice = () => {
      const width = window.innerWidth;
      const isAtMinWidth = width <= minWidth;

      const deviceType = getDeviceType(width);

      setDeviceInfo({
        deviceType,
        isMinWidth: isAtMinWidth,
        currentWidth: width,
      });

      // 如果有需要,可以持久化
      // localStorage.setItem("deviceType", deviceType);
    };

    const setViewport = () => {
      let viewport = document.querySelector('meta[name="viewport"]');

      if (!viewport) {
        viewport = document.createElement("meta");
        viewport.setAttribute("name", "viewport");
        document.head.appendChild(viewport);
      }

      if (preventZoom) {
        viewport.setAttribute(
          "content",
          "width=device-width, initial-scale=1.0, " +
            "minimum-scale=1.0, maximum-scale=1.0, " +
            "user-scalable=no, viewport-fit=cover"
        );
      } else {
        viewport.setAttribute(
          "content",
          "width=device-width, initial-scale=1.0"
        );
      }
    };

    const setMinWidthStyle = () => {
      const styleId = "min-width-style";
      let styleElement = document.getElementById(styleId) as HTMLStyleElement;

      if (!styleElement) {
        styleElement = document.createElement("style");
        styleElement.id = styleId;
        document.head.appendChild(styleElement);
      }

      styleElement.textContent = `
        body {
          min-width: ${minWidth}px;
          overflow-x: auto;
        }
        .container-limit {
          min-width: ${minWidth}px;
        }
        ${
          enableSafeAreas
            ? `
        .safe-area-bottom {
          padding-bottom: env(safe-area-inset-bottom);
        }
        .safe-area-left {
          padding-left: env(safe-area-inset-left);
        }
        .safe-area-right {
          padding-right: env(safe-area-inset-right);
        }
        `
            : ""
        }
      `;
    };

    const handleResize = () => {
      checkDevice();
      setViewport();
      setMinWidthStyle();
    };

    // 挂载后立即执行一次,尽量在首屏绘制前完成
    handleResize();

    // 监听 resize 做后续矫正,可以加防抖节流
    window.addEventListener("resize", handleResize);

    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, [minWidth, preventZoom, enableSafeAreas]);

  return deviceInfo;
};

使用时非常简单:

const { deviceType, currentWidth, isMinWidth } = useDeviceType();

if (deviceType === "desktop") {
  // ...
}

一些兼容性和架构层面的思考

即便上面这一套 UA + 宽度 + resize 的方案在大部分情况下能跑得比较稳,但仍有一些潜在问题:

  • UA 本身不可靠:被伪装、被浏览器修改;
  • 新设备 / 新系统版本出现新的 UA 组合,需要不断维护规则;
  • 某些嵌入式 WebView 或特殊浏览器的行为不确定。

从长期维护成本和复杂度来看,如果业务允许,我更推荐下面这种方式:

  • PCMobile/Pad 分两套代码
  • Mobile 内部用媒体查询区分 Phone / Tablet

前端高频面试题之CSS篇(一)

1、实现垂直居中的方式有哪些?

  • line-height:文本可以使用 line-height 等于容器高度。
  • Flex 布局:display: flex; align-items: center;)。
  • absolute + transform 或者 absolute + 负marginposition: absolute; top: 50%; transform: translateY(-50%);或者 position: absolute; top: 50%; margin-top: -50px;(容器高度为 100px)
  • absolute + margin: autoposition: absolute; inset: 0; margin: auto
  • Grid 布局display: grid; place-items: center;
  • table 布局display: table-cell;vertical-align: middle;

2、选择器权重和样式优先级是怎样的?

CSS 各选择器权重(从高到低):

选择器名称 选择器格式 权重
id选择器 #id 100
类选择器 .classname 10
属性选择器 [attr=value] 10
伪类选择器 li:first-child 10
标签选择器 a 1
伪元素选择器 div::after 1
相邻兄弟选择器 div+div 0
子选择器 div > a 0
后代选择器 div a 0
通配符选择器 * 0
  • 可以在样式后面使用 !important ,比如 display: none !important,此时该样式的权重最高,权重值为 Infinity,但需要慎用。
  • style内联样式的权重为1000
  • 权重相同,后出现的覆盖前面。

3、CSS 隐藏元素的方法有哪些?

  1. display:none: 元素不会渲染,不占据空间,也不响应绑定的事件。
  2. opacity: 0: 将元素的透明度设置为0,进而让元素从视觉上消失,但元素仍然会占据空间,并且会响应绑定的事件
  3. visibility: hidden: 这种方式隐藏会让元素依旧占据空间,但不会响应事件
  4. position: absolute;left: -9999px;top: -9999px;: 利用绝对定位将元素移到屏幕外。
  5. z-index: -9999: 降低元素层级,让当前元素被其它元素覆盖,间接达到元素隐藏的目的。
  6. overflow: hidden: 超出该元素范围内的元素将会隐藏显示。
  7. clip-path: inset(100%)(向内裁剪100%)或者clip-path: circle(0)(半径为0的圆形): 使用元素裁剪来实现元素隐藏。
  8. transform: scale(0,0): 利用 css3 的元素缩放能力,将元素缩放为0来实现元素的隐藏。

4、Link 和 @import 的区别

特性 link标签 @import
所属标准 XHTML/HTML标准 CSS标准
引用内容类型 可用于引入 CSS、RSS、图标等多种资源 仅支持css样式
加载时机 页面加载时同步加载 等待整个页面加载完成后再加载
JavaScript 控制 支持通过 DOM 操作修改样式链接 不支持动态控制
兼容性 无兼容问题 CSS2.1 才有的语法,在 IE5+ 才能识别
性能 更快,有利于首屏渲染 相对较慢,可能造成样式延迟加载

5、transition 和 animation 的区别

  • 过渡(transition):其核心是状态变化,如 transition: all 0.5s ease;,给所有属性加上一个 0.5s 的平滑过渡。
  • 动画(animation):其核心是对动画过程进行多帧关键帧控制,如 @keyframes name { 0% { ... } 100% { ... } },然后 animation: name 1s infinite;。动画相比过渡而言更加复杂,支持循环和暂停。

6、聊一聊盒模型

CSS 盒模型描述了元素在页面上的空间占用,包括内容(content)、内边距(padding)、边框(border)和外边距(margin)。

  • 标准盒模型(W3C 标准,默认盒模型):width 和 height 仅包含内容的宽度和高度,元素的总宽度 = width + 左右 padding + 左右 border + 左右 margin。
.box {
  box-sizing: content-box;
}
  • IE 盒模型(border-box),也叫怪异盒模型:元素的宽度 = width(width 里面包括内边距 padding 和边框 border) + 左右 margin。
.box {
  box-sizing: border-box;
}

7、聊一聊 CSS 预处理器

CSS 预处理器是一种工具或语言扩展,它可以让开发者以更加高级的语法来编写 CSS,比如可以定义变量、支持 CSS 嵌套写法、定义函数、使用循环等,在开发时可以提高我们的开发效率和项目的可维护性。但由于我们运行的平台,比如浏览器不支持这些高级语法,所以在代码的运行的时候,需要利用对应的工具编译成标准的 CSS。

CSS 常见的预处理器包括 SassLessStylusPostCSS 等。

8、什么是 CSS Sprites?

CSS Sprites 技术就是我们常说的雪碧图,通过将多张小图标拼接成一张大图,然后通过 CSS 的 background-imagebackground-position 属性来显示图像的特定部分,能有效的减少HTTP请求数量以达到加速显示内容的技术。

9、什么是BFC?它有什么作用?

BFC(block formatting context):简单来说,BFC 就是一种属性,这种属性会影响着元素的定位以及与其兄弟元素之间的相互作用。

形成 BFC 的条件:

  1. 浮动元素,floatnone 以外的值;
  2. 绝对定位元素,position(absolute,fixed)
  3. display 为以下其中之一的值:inline-blocks,table-cells,table-captions
  4. overflow 除了 visible 以外的值(hidden,auto,scroll)。

BFC常见作用:

  1. 包含浮动元素。
  2. 不被浮动元素覆盖。
  3. BFC 会阻止外边距折叠,可解决 margin 塌陷问题。

10、Flex 布局是什么?常用属性有哪些?

Flex(弹性盒布局)用于一维布局(如行或列),父容器设置 display: flex;

其常用属性如下:

  • 容器:flex-direction(方向)、justify-content(主轴对齐)、align-items(交叉轴对齐)、flex-wrap(换行)。
  • 子项:flex-grow(增长比例)、flex-shrink(收缩比例)、flex-basis(基础大小)。适合响应式设计。

11、flex:1 是哪些属性组成的?

flex 实际上是 flex-growflex-shrinkflex-basis 三个属性的缩写。

  • flex-grow:定义项目的的放大比例;
  • flex-shrink:定义项目的缩小比例;
  • flex-basis: 定义在分配多余空间之前,项目占据的主轴空间(main size),浏览器根据此属性计算主轴是否有多余空间。

12、flex-basis 和 width 的区别有哪些?

定义和作用:

  • flex-basis: 是 CSS Flexbox 布局中的专有属性,在其它地方使用不生效。
  • width 是一个通用的 CSS 属性。它适用于任何元素(块级、行内块等),不受布局模式限制。

优先级:

如果flex-basis的值为 auto(其默认值就是 auto,未显示设置采用的就是默认值),则 flex item 的初始大小会 fallbackwidth,也就是采用 width 设置的值作为初始大小,而如果 flex-basis 一旦设置了值,比如同时设置 width: 100px;flex-basis: 200px;, flex item 的初始大小会采用 flex-basis 设置的 200px

计算规则

  • flex-basis: flex item 的最终大小 = flex-basis + (剩余空间 * flex-grow) - (不足空间 * flex-shrink)
  • width,如果父级宽度足够,最终 flex-item 的最终大小 = width,如果空间不足,会进行宽度压缩, flex-item 的最终大小 < width,除非设置 flex-shark: 0,宽度就不会被压缩,最终宽度还是为 width的大小。

13、rem、em、px有什么区别?

  • **px(像素):**绝对单位,相对于屏幕分辨率大小固定。
  • rem(root em):CSS3 引入的相对长度单位,相对于 HTML 根元素的字体大小计算。可实现响应式布局。
  • em:是相对长度单位。相对于当前对象内文本的字体尺寸

这个很多人有个误解,em 在设置自身字体大小的时候是相对于父元素的字体大小; 在用 em设置其他属性单位的时候, 比如width,是相对于自身的字体属性大小, 只是很多时候自身字体属性是继承自父元素.

14、如何清除浮动?

浮动会导致父元素高度塌陷。清除方法如下:

  • 父元素添加 overflow: hidden;(触发 BFC)。
  • 使用伪元素:.clearfix::after { content: ''; display: block; clear: both; }
  • 父元素设置 floatdisplay: table;

现代布局更推荐使用 Flex/Grid 避免浮动。

15、CSS 性能优化的常见技巧?

  • CSS 加载性能优化:

    • 提取公共 CSS 文件。
    • 避免使用 @import
    • 压缩 CSS 文件。
    • 利用浏览器缓存。
    • 使用 CDN 加速。
    • 使用 CSS Sprite
    • CSS 样式抽离和去除无用 CSS
    • 合理使用内嵌 CSS
  • CSS 选择器性能优化:

    • 避免使用通配符选择器。
    • 使用子选择器代替后代选择器。
    • 优先使用类(Class)和 ID 选择器。
    • 避免深层嵌套的选择器。
  • CSS 选择器性能优化:

    • 避免使用过于复杂的属性。
    • 避免使用不必要的属性。
    • 避免使用 !important
  • CSS 动画性能优化:

    • 使用 transformopacity 属性来进行动画。
    • 避免使用过于复杂的动画效果。
    • 在动画中使用 will-change 属性。
    • 使用 requestAnimationFrame() 函数来优化动画。
  • CSS 渲染性能优化:

    • 使用 class 合并 DOM 的修改。
    • DOM 元素脱离文档流。

16、最后来一道考察 CSS 的 z-index 的面试真题

请按层级从高到低的顺序列出元素:

<body>
  <div id="dom-1" style="position: fixed; z-index: 100;">
    <div id="dom-2" style="position: absolute; z-index: 2000;"></div>
  </div>
  <div id="dom-3" style="position: relative; z-index: 1000;"></div>
</body>

这里主要考察 z-index 的两条层级计算规则:

  • 当父元素创建了一个层叠上下文 position: relative/absolute/fixed 时,此时父子元素的层级与 z-index 无关,就算 dom-2z-index99,其层级也比父级高。
  • 子元素的 z-index 受父元素限制。即使子元素 z-index 很高,如果父元素 z-index 低,整个子树都会在低层。

所以层级顺序从高到低依次是 dom-3 > dom-2 > dom-1

我们加一点样式就能很明显看出层级关系,代码如下:

<!-- z-index.html -->
<style>
  #dom-1 {
    width: 300px;
    height: 300px;
    background-color: red;
  }
  #dom-2 {
    width: 200px;
    height: 200px;
    background-color: blue;
  }
  #dom-3 {
    width: 100px;
    height: 100px;
    background-color: yellow;
  }
</style>
<body>
  <div id="dom-1" style="position: fixed; z-index: 100">
    <div id="dom-2" style="position: absolute; z-index: 2000"></div>
  </div>
  <div id="dom-3" style="position: relative; z-index: 1000"></div>
</body>

其渲染结果如下:

小结

以上是整理的一部分 CSS 的相关面试题,如有错误或者可以优化的地方欢迎评论区指正,后续还会更新 CSS 一些常见布局实现的面试题。

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. 优化用户体验:大屏加载更快,数据更同步,用户体验更好

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

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

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

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

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

大部分人都错了!这才是chrome插件多脚本通信的正确姿势 | 掘金一周 11.27

本文字数1500+ ,阅读时间大约需要 5分钟。

【掘金一周】本期亮点:

「上榜规则」:文章发布时间在本期「掘金一周」发布时间的前一周内;且符合各个栏目的内容定位和要求。 如发现文章有抄袭、洗稿等违反社区规则的行为,将取消当期及后续上榜资格。

一周“金”选

掘金一周 文章头图 1303x734.jpg

内容评审们会在过去的一周内对社区深度技术好文进行挖掘和筛选,优质的技术文章有机会出现在下方榜单中,排名不分先后。

前端

大部分人都错了!这才是chrome插件多脚本通信的正确姿势 @不一样的少年_

Chrome 浏览器其实就是把各种工作分开来做,谁负责啥都很清楚。主进程管大局,渲染进程负责把网页内容展示出来,网络进程专门搞数据传输,GPU进程让动画和视频更流畅,插件进程则让你装的各种扩展各自独立运行。

别再滥用 Base64 了——Blob 才是前端减负的正确姿势 @404星球的猫

Blob 最大的特点是纯客户端、零网络:数据一旦进入 Blob,就活在内存里,无需上传服务器即可预览、下载或进一步加工。

转转UI自动化走查方案探索 @转转技术团队

整个方案的核心其实就做了一件事:把两个看起来完全不同的东西(设计稿的JSON和HTML的DOM树),通过一系列归一化处理,变成可以直接比对的同构数据。这个过程中最大的感受是,前端开发和UI设计之间的gap,本质上是两套不同的渲染规则在互相较劲。

npm scripts的高级玩法:pre、post和--,你真的会用吗? @ErpanOmer

npm scripts,它不是一个简单的脚本快捷方式。它是一个工作流(Workflow)的定义 。prepost,定义了你工作流的执行顺序依赖,保证了代码检查等功能,而--是确保你工作流中的脚本参数

Vue高阶组件已过时?这3种新方案让你的代码更优雅 @良山有风来

HOC到Composition API,不仅仅是API的变化,更是开发思维的升级。 HOC代表的组件包装模式已经成为过去,而基于函数的组合模式正是未来。这种转变让我们的代码更加清晰、可测试、可维护。

后端

Spring 项目别再乱注入 Service 了!用 Lambda 封装个统一调用组件,爽到飞起 @只会写代码

其实这组件就干了 3 件事:1. 你传个 Lambda(比如UserService::queryUser),它帮你找到对应的 Service 实例;2. 把找到的实例和方法缓存起来,下次调用更快;3. 统一执行方法,顺便把日志、异常处理都包了。

Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术 @得物技术

HTTP请求看似简单,但它连接着整个系统的"血管"。忽视超时和重试,就像在血管上留了个缺口——平时没事,压力一来就大出血。构建高可靠的网络请求需要在超时控制、重试策略、幂等性保证和性能优化之间取得平衡。

Android

回顾 Flutter Flight Plans ,关于 Flutter 的现状和官方热门问题解答 @恋猫de小郭

在 Flutter 官方刚举行的 Flutter Flight Plans 直播里,除了发布 Flutter 3.38Dart 3.10 之外,其实还有不少值得一聊的内容,例如企业级的 Flutter 案例展示,Flutter + AI 的场景,重点还有针对大量热门问题的 Q&A(多窗口、GenUI、PC\Web 插件) 等

Android系统BUG:修改线程名目标错乱问题探究 @卓修武K

此次的问题发生原因是 三方地图SDK 重写了start()函数,又多次调用了start函数,导致滴滴的booster插件添加的setName逻辑也被多次触发,而此时调用setName的线程刚好是主线程,因此最终影响了 主进程名称

人工智能

Doubao-Seed-Code深度测评:一张设计稿生成完整网站,视觉理解编程模型全流程实战 @Nturmoils

即使有些模型通过MCP工具调用实现了"看图",但本质上是先把图片转成文字描述,再交给模型理解。这个过程中信息折损非常大,效果远不及原生VLM能力。Doubao-Seed-Code的视觉理解是模型训练阶段就内置的能力,可以直接"看懂"图片,识别UI布局、配色方案、设计细节,然后生成对应的代码。

如何实现 Remote MCP-Server @袋鼠云数栈UED团队

对于公司内部的MCP-Server, 由于隐私性问题不能发布为npm包,那么就没法以npx或者uvx等形式快速的共享使用。所以基本会以STDIO类型的MCP-Server进行开发,在内部进行共享时只能将对应源文件拉取本地使用。

社区活动日历

掘金官方 文章头图 1303x734.jpg

活动日历

活动名称 活动时间
🚀TRAE SOLO 实战赛 2025年11月13日-2025年12月16日

📖 投稿专区

大家可以在评论区推荐认为不错的文章,并附上链接和推荐理由,有机会呈现在下一期。文章创建日期必须在下期掘金一周发布前一周以内;可以推荐自己的文章、也可以推荐他人的文章。

通过<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> 的误区,同时呢也需要注意样式隔离、动画执行顺序等细节,这样就能实现完整的路由过渡效果。

浅记一下ElementPlus中的虚拟化表格(el-table-v2)的简单使用

我的需求

同时在一个表格中显示6个甚至更多的传感器数据,数据量在每个传感器每10秒1条数据,一般请求1天的数据。

以6个传感器计算一下数据量:

6 * 24 * 60 * 6 = 51840

一次只能请求到1个传感器的数据,也就是8640条,当查询时间范围变化时,循环请求接口获取数据,并且需要很快的渲染完毕。

效果像这样

image.png

我的经历

先使用的 Table(el-table)

调用一次接口反应时间(浏览器网络窗口查看)约在1-2s左右,一次接口的数据使用Table渲染一次时间约是4s左右。

最开始的想法

新添加一个传感器的数据,每一次需要重新渲染一次没有问题。

当切换查询时间后,循环调用时,采用的是——给tableData中直接添加。这会导致在循环调取数据时,每调取一次就会重新渲染一次。出现渲染卡顿问题,6个接口调用完毕且渲染完成,需要20-30s的时间,数据量大的时间会更长,有兴趣的朋友可以试一下。

<el-table v-loading="loading" element-loading-text="数据加载中":data="tableData"
  height="100%">
  <el-table-column :min-width="leftWidth" :fixed="index == 0" v-for="(column, index) in columns"
    :key="column.prop" :label="column.label" :prop="column.prop"></el-table-column>
</el-table>
······
tableData.value = tableData.value.map((item: any, index: number) => {
    return {
      ...item,
      ['data' + String(sensor.id)]: Number(res.data.Data[index].data).toFixed(3)
    }
})

对最开始的想法优化

在循环调用时,调用一次渲染一次非常慢,第一次优化是拿到所有的数据之后,再将数据添加至tableData,之后同意进行渲染,这种方式快了一点,但没有太大改善。

const buildTableFromAllData = (allFetched: Array<any>) => {
  if (allFetched.length === 0) return;

  // 第一步:构建 columns
  const newColumns = [
    { prop: 'time', label: '时间' }
  ];
  allFetched.forEach(({ device, gateway }) => {
    newColumns.push({
      prop: 'data' + String(sensor.id),
      label: sensor.name
    });
  });

  // 第二步:对齐时间轴(假设所有设备返回的时间点一致)
  const firstData = allFetched[0].rawData;
  const timeList = firstData.map((item: any) => dayjs(item.time).format('YYYY-MM-DD HH:mm:ss'));

  // 第三步:构建 tableData 行数据
  const newTableData = timeList.map((time: string, index: number) => {
    const row: Record<string, any> = { time };
    allFetched.forEach((_, colIndex) => {
      const value = allFetched[colIndex].rawData[index]?.data;
      const formatted = value ? Number(value).toFixed(3) : '';
      row['data' + String(_.sensor.id)] = formatted;
    });
    return row;
  });

  // 第四步:构建 lineData(用于图表)
  const newLineData = allFetched.map(fetched =>
    fetched.rawData.map((item: any) => Number(item.data).toFixed(3))
  );

  // ✅ 一次性赋值!只触发一次响应式更新
  columns.value = newColumns;
  tableData.value = newTableData;
};

虚拟化表格的接触

在AI的建议下使用虚拟化表格。

ElementPlus中的说明通过虚拟化表格组件,超大数据渲染将不再是一个头疼的问题

使用虚拟化表格之后,渲染速度至少提升一半时间以上。

<el-auto-resizer>
  <template #default="{ height, width }">
    <el-table-v2 v-loading="loading" element-loading-text="数据加载中" :columns="columns" :data="tableData"
      :width="width" :height="height" fixed />
  </template>
</el-auto-resizer>
······
const buildTableFromAllData = (allFetched: Array<any>) => {
  if (allFetched.length === 0) return;

  const newColumns: any[] = [
    {
      key: 'time',
      dataKey: 'time',
      title: '时间',
      width: 200 * Number(ratio.value),
      fixed: 'left'
    }
  ];

  allFetched.forEach(({ sensor }) => {
    newColumns.push({
      key: 'data' + String(sensor.id),
      dataKey: 'data' + String(device.Deviceid),
      title: sensor.name,
      width: 200 * Number(ratio.value)
    })
  });

  const firstData = allFetched[0].rawData;
  const timeList = firstData.map((item: any) => dayjs(item.time).format('YYYY-MM-DD HH:mm:ss'));

  const newTableData = timeList.map((time: string, index: number) => {
    const row: Record<string, any> = { time };
    allFetched.forEach((_, colIndex) => {
      const value = allFetched[colIndex].rawData[index]?.data;
      const formatted = value ? Number(value).toFixed(3) : '';
      row['data' + String(_.sensor.id)] = formatted;
    });
    return row;
  });
  
  const newLineData = allFetched.map(fetched =>
    fetched.rawData.map((item: any) => Number(item.data).toFixed(3))
  );

  // ✅ 一次性赋值!只触发一次响应式更新
  columns.value = newColumns;
  tableData.value = newTableData;
};

其它问题

问题

其实以上用法已经完全解决了我的问题,但是在电脑浏览器中使用无误,但是一换到手机浏览器或者平板浏览器中,虚拟表格直接滑不动,能看到滚动条,但是就是滑不动,以下是我的解决尝试过程。

AI给的解决方案——添加CSS

查询到滚动的内容是这样的<div class="el-vl__wrapper el-table-v2__body",于是我添加了下面的样式

:deep(.el-table-v2__main .el-table-v2__body) {
  -webkit-overflow-scrolling: touch !important;
  overflow-y: auto !important;
  overflow-x: auto !important;
  touch-action: pan-x pan-y !important;
}

结果就是没有结果,并没有解决我的问题。

最终解决方案——还是添加CSS,但略有不同

在审查元素的时候发现了一个东西,第二行div中的overflow: hidden;

<div class="el-vl__wrapper el-table-v2__body" role="rowgroup">
    <div class="" style="position: relative; overflow: hidden; will-change: transform; direction: ltr; height: 1313.11px; width: 2143.33px;"><div style="height: 3000px; width: 2137.33px;">
    ······
    </div>
</div>

怎么给body设置滚动都没有用,因为里面给hidden了,于是添加的CSS变成了这样

:deep(.el-table-v2__main .el-table-v2__body) {
  -webkit-overflow-scrolling: touch !important;
  overflow-y: auto !important;
  overflow-x: auto !important;
  touch-action: pan-x pan-y !important;
}

OK!成功解决问题,以上就是虚拟化表格的浅用过程,得下次有机会再深入使用!

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类型作为参数,因为是根节点

Antigravity 登录问题/数据泄露风险 (附:白嫖一个月 Gemini Enterprise 攻略)

1. Antigravity 登录问题

😄 最近 Google 的「哈基米3 (Gemini)-前端编程最强」和「Banana Pro-生图最强」各种刷屏,亲身体验下来确实 "实至名归"。推出的 AI IDE —— Antigravity (反重力) 也火了,各种群都有人在讨论 "登录问题" 怎么解决?🤣 其实,登不上无非两个原因,对症下药即可,详细解法看我发的上一篇文章🤷‍♀️。

🐶 海鲜市场的 Gemini 3.0 Pro (一年) 成品账号价格从开始的 15 涨到了现在的 80+ ,不多不说还是 "信息差" 赚钱啊。基本都是薅的 学生认证一年免费,群里发过教程了,这里就不赘述了。

2. Antigravity 输出中文

分享下热心群友「谢꯭盒꯭盒꯭ ®」发的 Antigravity 让模型输出中文的 Prompt:

1. Always respond in Chinese-simplified
2. You MUST conduct your internal reasoning and thinking process entirely in Simplified Chinese. This is a strict requirement.

点击 Chat 右上角的 ...,选中 Customizations

Rules 标签,点击 + Global 添加全局的 Rules,直接CV上面的 Prompt 就好了:

然后AI的 思考回答过程 都会返回中文,Nice 👍~

3. Antigravity 数据泄露风险

🤔 使用过程发现 BUG 挺多 (冗余按钮没处理、模型过载导致任务中断),体验不是特别好,而且昨天有人曝出 存在 "数据泄露风险"

《Antigravity Grounded! Security Vulnerabilities in Google's Latest IDE》

快速梳理下:

🤷‍♀️ 所以,暂时不太建议用 Antigravity 来开发公司项目,以免不必要的数据泄露风险,写写小玩具还是可以的~

4. 白嫖一个月 Gemini Enterprise

👏 谷歌 "大善人" 搞了个 Gemini Enterprise (企业级AI平台):

Geimi Enterprise

然后 商务版 提供了一个月的免费试用:

🤣 随便一个邮箱就能注册 (邮箱要能用,以便收到验证码进行登录,尽量开个新号,不要自己的gmail,以避免不要的麻烦,比如我就用的163邮箱),然后 不需要绑卡 (不用支付),就能直接畅用 Gemini 3Nano Banana Pro (具体额度是多少不知道,反正能蹬)。

😏 独乐乐,不如众乐乐,还能邀请 14 个人进入 "车队" 一起畅享 (填写对方邮箱,会发送一封邀请邮件,点击链接就能加入,不要使用 Gmail、QQ 邮箱,可能会收不到,填其它收不到的话,可以去垃圾邮件处找找看~)

😆 赶紧去试试吧,杰哥昨晚都在群里开了三个车了~

都React 19了,他到底带来了什么?

src=http___pic1.win4000.com_wallpaper_2020-07-22_5f17fe7fe9959.jpg&refer=http___pic1.win4000 (1).webp

如果你还在 React 应用的 useEffect里写 fetch请求,那你就做错了。我们每天都听到这种说法。但我们应该怎么做才对呢?好吧,事实证明,React 团队已经清楚地听到了我们的声音。React 19.2 不仅仅是修补异步问题——它通过 use()<Suspense>useTransition()以及现在的 View Transitions,从头重建了异步处理机制。这些基础构建块共同将异步逻辑从一个"必要的麻烦"转变为一流的架构特性。让我带你了解 React 的异步叙事是如何彻底改变的,以及它为什么对你的团队很重要。

旧方式:useEffect + isLoading

让我们从一个展示旧的 useEffect/ fetch组合模式的例子开始:

function ImageViewer() {
  const [imageId, setImageId] = useState(1);
  const [imageData, setImageData] = useState(null);
  const [isPending, setIsPending] = useState(false);

  useEffect(() => {
    setIsPending(true);
    fetchImage(imageId).then((data) => {
      setImageData(data);
      setIsPending(false);
    });
  }, [imageId]);

  return (
    <div>
      <button onClick={() => setImageId((id) => id + 1)}>Next Image</button>
      {isPending ? <span>Loading...</span> : }
    </div>
  );
}

(这里的 fetchImage只是 fetch的一个包装函数,以保持示例简短。)

这个模式确实能用,但你做了很多重复性的工作。例如,你管理了三个状态(imageId, imageData, isPending),而实际上你只是想显示一张图片。加载状态逻辑是手动的,错误处理也常常是事后才考虑。而且很可能,你的代码库中每个异步操作看起来都略有不同。

最糟糕的是,那个 useEffect的依赖数组是个隐患。漏掉一个依赖项,你会得到过时的闭包;包含太多依赖项,又会触发无限循环。这是无数生产环境 bug 的根源。

这并不理想,老实说,用这种代码你能做到的最好程度就是"不搞砸"。而这并不是你想要编写的代码类型。

新的异步基础构建块

React 19.2 给我们提供了一种完全不同的方法。我们不再用 useEffect来管理 Promise,而是直接使用 use()<Suspense>来处理 Promise。

核心模式:use() + <Suspense>

下面是使用 React 新的 useHook 和 Suspense组件组合重写的同一个组件:

import { use, Suspense, useState } from "react";

function ImageViewer() {
  const [imageId, setImageId] = useState(1);
  const [imageDataPromise, setImageDataPromise] = useState(() => fetchImage(1));

  const handleNext = () => {
    const nextId = imageId + 1;
    setImageId(nextId);
    setImageDataPromise(fetchImage(nextId));
  };

  return (
    <div>
      <button onClick={handleNext}>Next Image</button>
      <Suspense fallback={<ImageSkeleton />}>
        <Image imageDataPromise={imageDataPromise} />
      </Suspense>
    </div>
  );
}

function Image({ imageDataPromise }) {
  const image = use(imageDataPromise);
  return (
    <div>
      <h2>{image.title}</h2>
      
    </div>
  );
}

看看这有多简洁。没有 useEffect。没有手动的 isPending状态。父组件中没有条件渲染逻辑。我们存储的是一个 Promise 而不是数据,React 会处理剩下的事情。

但这是如何工作的呢?<Suspense>背后的魔法

<Suspense>尝试渲染其子组件时,<Image>组件会调用 use(imageDataPromise)。如果那个 Promise 还没有解决,use()会直接抛出这个 Promise。是的——像抛出异常一样抛出它。

我知道你在想什么:"用异常来控制流程?这太奇怪了!"但请听我说,因为这其实非常巧妙。

那个被抛出的 Promise 会向上冒泡穿过组件树,直到被 <Suspense>捕获。<Suspense>于是知道:"啊,我的子组件需要从这个 Promise 获取数据。我会显示我的加载状态(fallback),并等待这个 Promise 解决。"

当 Promise 解决后,<Suspense>会重新渲染其子组件,use()返回数据,图片就显示出来了。

这消除了在组件树的每个层级进行条件渲染的需要。加载状态变得声明式,因为你可以用 <Suspense>包装异步组件,并定义在加载时显示什么。React 会处理时机。

更流畅的交互:useTransition() + action

但我们可以让它变得更好。目前,当你点击 "Next Image" 时,按钮在新图片加载期间仍然可点击。这不是很好的用户体验;用户可能会多次点击,造成竞态条件。React 19.2 引入了 action props 和 useTransition()来优雅地处理这种情况:

function Button({ action, children }) {
  const [isPending, startTransition] = useTransition();

  const onClick = () => {
    startTransition(async () => {
      await action();
    });
  };

  return (
    <button onClick={onClick} disabled={isPending}>
      {children}
    </button>
  );
}

function ImageViewer() {
  const [imageId, setImageId] = useState(1);
  const [imageDataPromise, setImageDataPromise] = useState(() => fetchImage(1));

  const handleNext = async () => {
    const nextId = imageId + 1;
    setImageId(nextId);
    setImageDataPromise(fetchImage(nextId));
  };

  return (
    <div>
      <Button action={handleNext}>Next Image</Button>
      <Suspense fallback={<ImageSkeleton />}>
        <Image imageDataPromise={imageDataPromise} />
      </Suspense>
    </div>
  );
}

名为 action的 prop 本身并没有什么特别之处。它只是一个约定,告诉其他开发者这个按钮会将处理器包装在一个过渡(transition)中。

View Transitions:GPU 加速的润色

既然我们谈到了异步的 UI 部分,让我们聊聊 View Transitions。React 19.2(在实验性分支上)添加了对浏览器原生 View Transitions API 的支持。这让你在异步内容加载时能获得如黄油般顺滑的、GPU 加速的动画。而且只需要几行代码。

首先,添加一些 CSS 动画:

@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}
@keyframes fadeOut {
  from { opacity: 1; }
  to { opacity: 0; }
}
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

::view-transition-old(image-container) {
  animation: fadeOut 0.3s ease-out;
}
::view-transition-new(image-container) {
  animation: fadeIn 0.3s ease-in;
}
::view-transition-old(button-pulse) {
  animation: pulse 0.3s ease-in-out;
}

然后将 View Transitions 添加到你的组件中:

import { experimental_useViewTransition as useViewTransition } from "react";

function Image({ imageDataPromise }) {
  const image = use(imageDataPromise);
  return ;
}

function ImageSkeleton() {
  return <div className="image-skeleton" />;
}

function Button({ action, children, disabled }) {
  const [isPending, startTransition] = useTransition();
  const viewTransition = useViewTransition();

  const onClick = () => {
    startTransition(async () => {
      await action();
    });
  };

  return (
    <div {...viewTransition("button-pulse")}>
      <button onClick={onClick} disabled={isPending}>
        {children}
      </button>
    </div>
  );
}

function ImageViewer() {
  const [imageId, setImageId] = useState(1);
  const [imageDataPromise, setImageDataPromise] = useState(() => fetchImage(1));

  const handleNext = async () => {
    const nextId = imageId + 1;
    setImageId(nextId);
    setImageDataPromise(fetchImage(nextId));
  };

  return (
    <div>
      <Button action={handleNext}>Next Image</Button>
      <div {...useViewTransition("image-container")}>
        <Suspense fallback={<ImageSkeleton />}>
          <Image imageDataPromise={imageDataPromise} />
        </Suspense>
      </div>
    </div>
  );
}

就这样。浏览器会自动处理 GPU 加速的过渡动画。你的骨架屏淡出,你的图片淡入,你的按钮在加载时会脉动。效果非常丝滑,而你几乎没写什么代码。

注意: 你需要 React 19.2 实验版才能使用此功能:npm install react@experimental react-dom@experimental

这对前端团队为何重要

这些改变不仅仅是清理 useEffect/ fetch的烂摊子。它关乎构建一个更优秀的 React,这个 React 终于承认异步是一等公民。

这些模式意味着你的团队编写的代码更容易理解和维护。框架处理了更多事情。UI 响应更迅捷,因为它是按需更新的。你正在利用更多底层框架的能力,为你的应用赋予具有流畅动画的现代感。

这不再是"你爷爷辈的 React"了,是时候让团队开始使用这些新工具了。

关键要点

  • React 19.2 带来了"无处不在的异步"。

  • 这些基础构建块作为一个系统协同工作:

    • use()从 Promise 中提取数据
    • <Suspense>声明式地处理加载状态
    • useTransition()免费为你提供加载标志
    • View Transitions 添加 GPU 加速的润色
  • 旧方式(useEffectfetch)对 React 来说是个严重的痛点,但我们有了很好的替代方案。

  • React 的异步基础构建块意味着更少的代码、更少的 bug 和更好的用户体验。

  • 你的团队可以更专注于构建体验,而不是支撑它的底层管道。

非 19.2 的选择:TanStack Query

话虽如此,如果你还没准备好升级到 React 19.2,有一个很好的替代方案:TanStack Query(前身为 React Query)。它适用于任何 React 版本,并为你提供自动缓存、后台重新获取、乐观更新等功能。它是一个久经考验的解决方案,解决了与 React 19.2 异步基础构建块相同的问题,只是 API 不同。有充分的理由说明为什么这么多 React 应用都安装了 react-query

下一步:React 19.2 引领的前端未来

React 19.2 已经发布。它是稳定的。你应该将其用于生产环境(除了仍在完善中的 View Transitions)。

异步变革已经到来。React 终于解决了它最大的痛点,框架也因此变得更好。

如果你还没有探索过 React 19.2,现在是时候了。在一个副项目上试试 use()<Suspense>,看看异步逻辑变得多么简单。你会惊讶于自己以前没有它是怎么过的。

被国外IP持续DDOS怎么办?

logo.png

最近持续被东南亚的几个IP持续攻击,我用的是阿里云的免费应用防火墙,一直没什么好办法。今天看到了几个免费的IP库,一下子就想到了如果我直接屏蔽这些来源国家的访问不就好了。

在网站运营、网络安全或流量管控场景中,按国家/地区限制IP访问(如仅允许国内IP访问、屏蔽特定国家IP)是常见需求。实现这一需求的核心步骤是:获取目标国家的IP段 → 将IP段配置到 Nginx 中生效。本文将详细讲解如何通过Node.js或Shell脚本获取国家IP段,并结合 Nginx 的geo模块完成配置。

一、选择 IP 数据源

获取国家 IP 段的前提是选择可靠的数据源,目前主流免费/商用数据源包括:

1. MaxMind GeoLite2

MaxMind 是全球知名的 IP 地理信息服务商,提供免费的 GeoLite2 数据库(需注册账号),包含 IPv4/IPv6 的国家 / 地区映射,数据精度高且更新及时(每月更新)。

2. apnic

apnic提供免费可直接获取的IP信息,直接下载就能获取最新的IP数据信息。

3. 其他

  • ip2region:国内开源的 IP 定位库,支持国家 / 城市级映射,数据文件小(约 10MB),查询速度快。
  • 17monip(纯真 IP):国内常用的 IP 库,需定期下载更新包。
  • IP2Location LITE:类似 GeoLite2 的免费数据库,提供 CSV/MMDB 格式。

本文以apnic为例讲解(兼容性和易用性最优)。

二、获取国家 IP 段的方法

方法 1:使用bash脚本获取并处理

这种方式适合linux系统下的简单使用,兼容centos、ubuntu等系统。

步骤一:获取数据

可以使用wget或者curl这种命令直接从远程下载脚本,脚本地址取最新数据http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest,脚本内容类似:

wget -c -O /tmp http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest

curl -C - -o /tmp http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest

步骤二:处理数据

官方提供的数据通常都是txt格式的,逐行进行排列的数据。我们要使用还需要对数据进行解析,把我们需要的数据单独获取出来。可以使用脚本的awk命令进行处理。

awk的命令格式:

awk [参数] [处理内容] [操作对象]

完整脚本

我们假设要把数据处理成“起始IP|数量/前缀长度|注册机构|国家代码”的格式,按照这个逻辑,再加上错误处理,我们输出以下脚本:

#!/bin/bash
set -eu  # 移除 pipefail,兼容低版本 Bash,保留关键错误检查

# 脚本配置
URL="http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest"
DOWNLOAD_DIR="/root/apnic"
DOWNLOAD_FILENAME="delegated-apnic-latest"
IPV4_OUTPUT_FILENAME="apnic_ipv4.txt"
IPV6_OUTPUT_FILENAME="apnic_ipv6.txt"

# 帮助信息
show_help() {
    echo "用法:$0 --download-dir <下载目录> [--temp-dir <临时目录>]"
    echo "选项:"
    echo "  --download-dir  可选,文件下载保存目录"
    echo "  -h/--help       显示帮助信息"
}

# 解析命令行参数
parse_args() {

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --download-dir)
                DOWNLOAD_DIR="$2"
                shift 2
                ;;
            -h|--help)
                show_help
                exit 0
                ;;
            *)
                echo "错误:未知参数 $1"
                show_help
                exit 1
                ;;
        esac
    done
}

# 检查依赖工具(wget 或 curl)
check_dependencies() {
    if command -v wget &> /dev/null; then
        DOWNLOAD_TOOL="wget"
    elif command -v curl &> /dev/null; then
        DOWNLOAD_TOOL="curl"
    else
        echo "错误:未找到 wget 或 curl,请先安装其中一个工具"
        exit 1
    fi
}

# 下载文件 + 校验文件有效性
download_file() {
    local download_path="${DOWNLOAD_DIR}/${DOWNLOAD_FILENAME}"
    echo "=== 开始下载文件 ==="
    echo "URL: $URL"
    echo "保存路径: $download_path"

    # 检查下载目录是否存在
    if [[ ! -d "$DOWNLOAD_DIR" ]]; then
        mkdir -p "$DOWNLOAD_DIR" || {
            echo "错误:无法创建保持目录 $DOWNLOAD_DIR"
            exit 1
        }
    fi

    # 下载文件(支持断点续传)
    if [[ "$DOWNLOAD_TOOL" == "wget" ]]; then
        wget -c -O "$download_path" "$URL" || {
            echo "错误:wget 下载失败(可能是网络问题或服务器异常)"
            exit 1
        }
    else
        curl -C - -o "$download_path" "$URL" || {
            echo "错误:curl 下载失败(可能是网络问题或服务器异常)"
            exit 1
        }
    fi

    # 校验下载文件是否为空
    if [[ ! -s "$download_path" ]]; then
        echo "错误:下载的文件为空,请检查 URL 或网络连接"
        rm -f "$download_path"  # 删除空文件
        exit 1
    fi

    echo "=== 文件下载完成(大小:$(du -sh "$download_path" | awk '{print $1}'))==="
    echo "下载文件路径: $download_path"
    echo
    return 0
}

# 解析并筛选 IPv4/IPv6(容错增强)
parse_and_filter_ip() {
    local download_path="${DOWNLOAD_DIR}/${DOWNLOAD_FILENAME}"
    local ipv4_output="${DOWNLOAD_DIR}/${IPV4_OUTPUT_FILENAME}"
    local ipv6_output="${DOWNLOAD_DIR}/${IPV6_OUTPUT_FILENAME}"

    echo "=== 开始解析文件 ==="
    echo "源文件: $download_path"
    echo "解析目录: $DOWNLOAD_DIR"

    # 清空输出文件(避免残留旧数据)
    > "$ipv4_output"
    > "$ipv6_output"

    # 筛选规则:
    # 1. 跳过注释行(以 # 开头)和空行
    # 2. 第 3 列(type)为 ipv4/ipv6 的行
    # 3. 输出格式:起始IP|数量/前缀长度|注册机构|国家代码(字段 4|5|1|2)
    # 用 awk 处理,即使部分行格式异常也不中断
    awk -F '|' '
        !/^#/ && NF >= 6 {  # 只处理非注释行且字段数≥6的行
            if ($3 == "ipv4") {
                print $4 "|" $5 "|" $1 "|" $2 >> "'"$ipv4_output"'"
            } else if ($3 == "ipv6") {
                print $4 "|" $5 "|" $1 "|" $2 >> "'"$ipv6_output"'"
            }
        }
    ' "$download_path" || {
        echo "警告:部分行格式异常,已跳过"
    }

    # 检查筛选结果(允许其中一种IP类型为空,避免误判)
    if [[ -s "$ipv4_output" ]]; then
        echo "IPv4 筛选完成,记录数:$(wc -l < "$ipv4_output") 行"
    else
        echo "警告:未筛选到 IPv4 数据(可能文件格式变更或无相关记录)"
    fi

    if [[ -s "$ipv6_output" ]]; then
        echo "IPv6 筛选完成,记录数:$(wc -l < "$ipv6_output") 行"
    else
        echo "警告:未筛选到 IPv6 数据(可能文件格式变更或无相关记录)"
    fi

    echo "=== 解析筛选完成 ==="
    echo "IPv4 文件路径: $ipv4_output"
    echo "IPv6 文件路径: $ipv6_output"
    echo
    return 0
}

# 主流程
main() {
    parse_args "$@"
    check_dependencies
    download_file
    parse_and_filter_ip

    echo "=== 任务全部完成 ==="
}

# 启动脚本
main "$@"

脚本首先设置默认变量,也就是默认的存储位置是/root/apnic,然后再把最新数据下载到文件里,通过命令筛选我们需要的数据分别存到对应的文件中。

方法 2:Node.js处理数据

使用nodejs整体流程设计上就不需要临时存储文件了,我们直接每次获取之后在内存中就全部处理完成。整体处理如下:

const axios = require('axios');
// 要允许通过的国家
const ALLowList = ['CN', 'HK', 'MO', 'TW'];

// 获取远程数据
async function getApnicTxt() {
    const res = await axios.get('http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest');
    return res.data;
}

// apnic|BD|ipv4|103.132.248.0|1024|20190116|allocated
// apnic|ID|ipv6|2001:df0:f180::|48|20190718|assigned
function createIpRecord(line, dot = '24') {
    const parts = line.split('|');
    if (ALLowList.includes(parts[1])) {
        return `allow ${parts[3]}/${dot}; #${parts[1]}`;
    }
    return `deny ${parts[3]}/${dot}; #${parts[1]}`;
}

async function main() {
    const txts = await getApnicTxt();
    const list = txts.split('\n');
    const ipv4list = [];
    const ipv6list = [];
    for (let index = 0; index < list.length; index++) {
        const txt = list[index];
        if (txt.includes('|ipv4|')) {
            ipv4list.push(createIpRecord(txt));
        }
        if (txt.includes('|ipv6|')) {
            ipv6list.push(createIpRecord(txt, '32'));
        }
    }
    console.log('ipv4list', ipv4list);
    console.log('ipv4list', ipv6list);
}

main();

脚本中同样是先获取最新的数据,然后直接交给处理函数进行分批处理。这里我们假设处理的结果是给nginx使用的,所以我们直接在结构上增加allowdeny前缀,方便在nginx中直接使用。

三、配置nginx文件

方法1:使用allow进行管理

这种配置方式兼容新老版本的nginx,只需要在nginx中提前引入配置文件,每次更新配置文件内容之后重启nginx就能达到自动允许和拒绝来源国家的访问地址。

server {
    listen 80;
    listen [::]:80;
    server_name your-domain.com;

    # 引入允许的IP白名单
    include /etc/nginx/conf.d/china-ipv4.conf;
    include /etc/nginx/conf.d/china-ipv6.conf;

    # 可选:放行本地回环(避免自己被拦)
    allow 127.0.0.1;
    allow ::1;

    # 拒绝所有未匹配的请求
    deny all;

    location / {
        root /var/www/html;
        index index.html;
        # 你的其他配置...
    }
}

通过以上配置就可以达到只允许我们需要的访问,其他国家访问一律禁止。

方法2:使用geo模块

Nginx 通过geo模块(默认编译)实现 IP 段的快速匹配,该模块将 IP 段加载到内存中,查询效率极高(不受 IP 段数量影响)。

http {
    # 定义IPv4的国家匹配变量($is_cn_v4:1=中国IP,0=非中国IP)
    geo $is_cn_v4 {
        default 0;
        include /etc/nginx/conf.d/cn-ipv4.conf;  # 引入中国IPv4段文件
    }

    # 定义IPv6的国家匹配变量(如需支持IPv6)
    geo $is_cn_v6 {
        default 0;
        include /etc/nginx/conf.d/cn-ipv6.conf;  # 引入中国IPv6段文件
    }

    # 合并IPv4/IPv6的匹配结果
    map $scheme $is_cn {
        http $is_cn_v4;
        https $is_cn_v4;
        httpv6 $is_cn_v6;
        httpsv6 $is_cn_v6;
    }

    server {
      listen 80;
      listen [::]:80;
      server_name yourdomain.com;

      # 非中国IP返回403
      if ($is_cn != 1) {
          return 403;
      }

      # 正常业务配置...
      location / {
          root /var/www/html;
          index index.html;
      }
  }
}

通过使用nginx的geo模块可以非常快速的识别到ip对应的国家,然后在具体的路由中通过自动一判断来返回自己想要返回的内容。同样的,配置文件更新之后要重新启动nginx服务。

其他命令

检查nginx配置文件是否正确。

nginx -t

重新启动nginx。

systemctl restart nginx

严重IP匹配的效果。

curl -H "X-Forwarded-For: 8.8.8.8" http://yourdomain.com  # 8.8.8.8为美国IP

四、注意事项

在具体的使用过程中还有一些是需要特别注意的:

  1. geo和allow的配置文件格式是不一样的。
  2. 如果使用云服务做转发,来源ip是云服务的地址,需要额外增加IP透传的功能。
  3. 远程地址的更新没那么快,可以设置每隔一周来定时更新一次。持续访问可能会被官方封禁哦。

还在用 WebSocket 做实时通信?SSE 可能更简单

大家好,我是大华!在现代的Web开发中,实时通信需求越来越普遍。比如在线聊天、实时数据监控、消息推送等场景。

面对这些需求,我们通常有两种选择:SSEWebSocket。它们都能实现实时通信,但设计理念和适用场景却有很大不同。

什么是 SSE?

SSE(Server-Sent Events)是一种基于 HTTP 的服务器推送技术。它的核心特点是:单向通信,只能由服务器向客户端发送数据。

SSE 的核心特点

  • 基于 HTTP 协议:使用标准的 HTTP/1.1 协议
  • 单向通信:服务器 → 客户端
  • 自动重连:浏览器内置重连机制
  • 简单易用:API 设计简洁直观
  • 文本传输:主要支持 UTF-8 文本数据

SSE 使用示例

客户端JS代码:

// 创建 SSE 连接
const eventSource = new EventSource('/api/real-time-data');

// 监听服务器推送的消息
eventSource.onmessage = function(event) {
  const data = JSON.parse(event.data);
  console.log('收到实时数据:', data);
  updateUI(data); // 更新界面
};

// 监听自定义事件类型
eventSource.addEventListener('systemAlert', function(event) {
  const alertData = JSON.parse(event.data);
  showAlert(alertData.message);
});

// 错误处理 - 自动重连是内置的
eventSource.onerror = function(event) {
  console.log('连接异常,正在自动重连...');
};

服务器端代码(Node.js + Express):

app.get('/api/real-time-data', (req, res) => {
  // 设置 SSE 必需的响应头
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'Access-Control-Allow-Origin': '*'
  });
  
  // 发送初始连接确认
  res.write('data: {"status": "connected"}\n\n');
  
  // 模拟实时数据推送
  let count = 0;
  const interval = setInterval(() => {
    const data = {
      id: count++,
      timestamp: new Date().toISOString(),
      value: Math.random() * 100
    };
    
    // SSE 标准格式:data: 开头,两个换行符结尾
    res.write(`data: ${JSON.stringify(data)}\n\n`);
    
    // 每10秒发送一次系统状态
    if (count % 10 === 0) {
      res.write('event: systemAlert\n');
      res.write(`data: {"message": "系统运行正常"}\n\n`);
    }
  }, 1000);
  
  // 客户端断开连接时清理资源
  req.on('close', () => {
    clearInterval(interval);
    console.log('客户端断开连接');
  });
});

什么是 WebSocket?

WebSocket 是一种真正的全双工通信协议,允许服务器和客户端之间建立持久连接,进行双向实时通信。

WebSocket 的核心特点

  • 独立协议:基于 TCP 的独立协议(ws:// 或 wss://)
  • 双向通信:服务器 ↔ 客户端
  • 低延迟:建立连接后开销极小
  • 数据多样:支持文本和二进制数据
  • 手动管理:需要手动处理连接状态

WebSocket 使用示例

客户端JS代码:

class ChatClient {
  constructor() {
    this.socket = null;
    this.isConnected = false;
  }
  
  connect() {
    this.socket = new WebSocket('wss://api.example.com/chat');
    
    this.socket.onopen = () => {
      this.isConnected = true;
      console.log('WebSocket 连接已建立');
      this.send({ type: 'join', username: '小明' });
    };
    
    this.socket.onmessage = (event) => {
      const data = JSON.parse(event.data);
      this.handleMessage(data);
    };
    
    this.socket.onclose = () => {
      this.isConnected = false;
      console.log('连接已断开');
      this.attemptReconnect();
    };
    
    this.socket.onerror = (error) => {
      console.error('WebSocket 错误:', error);
    };
  }
  
  sendMessage(content) {
    if (this.isConnected) {
      this.send({
        type: 'message',
        content: content,
        timestamp: Date.now()
      });
    }
  }
  
  send(data) {
    this.socket.send(JSON.stringify(data));
  }
  
  handleMessage(data) {
    switch (data.type) {
      case 'chat':
        this.displayMessage(data);
        break;
      case 'userJoin':
        this.showUserJoin(data.username);
        break;
    }
  }
  
  attemptReconnect() {
    setTimeout(() => {
      console.log('尝试重新连接...');
      this.connect();
    }, 3000);
  }
}

服务器端代码(Node.js + ws 库):

const WebSocket = require('ws');
const wss = new WebSocket.Server({ 
  port: 8080,
  perMessageDeflate: false
});

// 存储连接的用户
const connectedUsers = new Map();

wss.on('connection', (ws, request) => {
  console.log('新的客户端连接');
  
  let currentUser = null;
  
  ws.on('message', (rawData) => {
    try {
      const data = JSON.parse(rawData);
      
      switch (data.type) {
        case 'join':
          currentUser = data.username;
          connectedUsers.set(ws, currentUser);
          
          // 广播用户加入消息
          broadcast({
            type: 'userJoin',
            username: currentUser,
            time: new Date().toISOString()
          }, ws);
          
          // 发送欢迎消息
          ws.send(JSON.stringify({
            type: 'system',
            message: `欢迎 ${currentUser} 加入聊天室!`
          }));
          break;
          
        case 'message':
          // 广播聊天消息
          broadcast({
            type: 'chat',
            username: currentUser,
            message: data.content,
            timestamp: data.timestamp
          });
          break;
      }
    } catch (error) {
      console.error('消息解析错误:', error);
      ws.send(JSON.stringify({
        type: 'error',
        message: '消息格式错误'
      }));
    }
  });
  
  ws.on('close', () => {
    if (currentUser) {
      connectedUsers.delete(ws);
      // 广播用户离开
      broadcast({
        type: 'userLeave',
        username: currentUser,
        time: new Date().toISOString()
      });
    }
    console.log('客户端断开连接');
  });
  
  // 心跳检测
  const heartbeat = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type: 'ping' }));
    }
  }, 30000);
  
  ws.on('close', () => {
    clearInterval(heartbeat);
  });
});

function broadcast(data, excludeWs = null) {
  wss.clients.forEach((client) => {
    if (client !== excludeWs && client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify(data));
    }
  });
}

区别对比

特性 SSE WebSocket
通信模式 单向(服务器推客户端) 双向(全双工通信)
协议基础 HTTP/1.1 独立的 WebSocket 协议
连接建立 普通 HTTP 请求 HTTP 升级握手
数据格式 文本(事件流格式) 文本和二进制帧
重连机制 浏览器自动处理 需要手动实现
头部开销 每次消息带 HTTP 头 建立后极小帧头
兼容性 良好(除 IE) 优秀(IE10+)
开发复杂度 简单直观 相对复杂

适用场景

推荐使用 SSE 的场景

1. 实时数据监控面板 2. 实时消息通知 3. 实时数据流展示

比如:股票价格实时更新、体育比赛比分直播、物流订单状态跟踪和服务器日志实时显示等。

推荐使用 WebSocket 的场景

1. 实时交互应用 2. 实时游戏应用 3. 实时音视频通信

比如:视频会议系统、在线客服聊天和实时协作编辑文档等

如何选择?

选择 SSE:

  • 只需要服务器向客户端推送数据
  • 希望快速实现、简单维护
  • 项目对移动端兼容性要求高
  • 数据更新频率适中(秒级)
  • 不需要传输二进制数据

选择 WebSocket:

  • 需要真正的双向实时通信
  • 数据传输频率很高(毫秒级)
  • 需要传输二进制数据(如图片、音频)
  • 构建实时交互应用(游戏、协作工具)
  • 对延迟极其敏感的场景

混合使用策略

在一些复杂应用中,可以同时使用两者:

class HybridApp {
  constructor() {
    // 使用 SSE 接收通知和广播消息
    this.notificationSource = new EventSource('/api/notifications');
    
    // 使用 WebSocket 进行实时交互
    this.interactionSocket = new WebSocket('wss://api.example.com/interact');
    
    this.setupEventHandlers();
  }
  
  setupEventHandlers() {
    // SSE 处理广播类消息
    this.notificationSource.onmessage = (event) => {
      this.handleBroadcastMessage(JSON.parse(event.data));
    };
    
    // WebSocket 处理交互类消息
    this.interactionSocket.onmessage = (event) => {
      this.handleInteractionMessage(JSON.parse(event.data));
    };
  }
}

总结

SSE 的优势在于简单易用、自动重连、与 HTTP 基础设施完美集成,适合服务器向客户端的单向数据推送场景。

WebSocket 的优势在于真正的双向通信、低延迟、支持二进制数据,适合需要高频双向交互的复杂应用。

在实际项目中,可以根据具体的业务需求、性能要求来做出合理的技术选型。

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

📌往期精彩

《SpringBoot+Vue3 整合 SSE 实现实时消息推送》

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《SpringBoot 动态菜单权限系统设计的企业级解决方案》

《Vue3 + ElementPlus 动态菜单实现:一套代码完美适配多角色权限系统》

CopilotKit-丝滑连接agent和应用-理论篇

CopilotKit是什么

CopilotKit 将应用的逻辑、状态和用户上下文连接到 AI 代理。提供了构建、部署和监控 AI 辅助功能的工具,这些功能感觉直观、有用且深度集成。

CopilotKit特点

  • Generative UI(生成式 UI):使用自定义 UI 组件实时呈现agent的状态、进度、输出和工具调用。
  • Human in the Loop(人机协同):用户可以在关键点指导agent。实现更可靠的输出。
  • Shared State(共享状态):agent和应用状态保持同步。agent可以查看应用中的所有状态,应用可以实时响应agent

CopilotKit和LangGraph的关系

image.png

聊天组件

前三个组件由@copilotkit/react-ui提供。

  1. CopilotChat :灵活的聊天界面组件。
  2. CopilotSidebar :可折叠和扩展的侧边栏聊天组件。
  3. CopilotPopup : 可以打开和关闭的浮动聊天组件。
  4. HeadLess UI : 支持自定义组件。
    import { useCopilotChat } from "@copilotkit/react-core";
    import { Role, TextMessage } from "@copilotkit/runtime-client-gql";
    
    export function CustomChatInterface() {
    const {
        visibleMessages,
        appendMessage,
        setMessages,
        deleteMessage,
        reloadMessages,
        stopGeneration,
        isLoading,
    } = useCopilotChat();
    
    const sendMessage = (content: string) => {
        appendMessage(new TextMessage({ content, role: Role.User }));
    };
    
    return (
        <div>
        {/* Implement your custom chat UI here */}
        </div>
    );
    }
    

设置聊天组件的样式

  • 样式:主要思路是通过样式覆盖的方式进行组件样式的设定。支持通过css变量、css类的方式进行组件样式和字体的定义。
  • 图标:通过组件的icons属性进行定义。
  • 标签:通过组件的labels属性进行自定义。

具体类名、变量名、icons属性、labels属性参考这里

自定义子组件

支持通过组件的props将自定义的子组件传递进去,从而实现子组件的自定义。

import { type AssistantMessageProps } from "@copilotkit/react-ui";
import { useChatContext } from "@copilotkit/react-ui";
import { Markdown } from "@copilotkit/react-ui";
import { SparklesIcon } from "@heroicons/react/24/outline";

import { CopilotKit } from "@copilotkit/react-core";
import { CopilotSidebar } from "@copilotkit/react-ui";
import "@copilotkit/react-ui/styles.css";

const CustomAssistantMessage = (props: AssistantMessageProps) => {
  const { icons } = useChatContext();
  const { message, isLoading, subComponent } = props;

  const avatarStyles = "bg-zinc-400 border-zinc-500 shadow-lg min-h-10 min-w-10 rounded-full text-white flex items-center justify-center";
  const messageStyles = "px-4 rounded-xl pt-2";

  const avatar = <div className={avatarStyles}><SparklesIcon className="h-6 w-6" /></div>

  return (
    <div className="py-2">
      <div className="flex items-start">
        {!subComponent && avatar}
        <div className={messageStyles}>
          {message && <Markdown content={message.content || ""} /> }
          {isLoading && icons.spinnerIcon}
        </div>
      </div>
      <div className="my-2">{subComponent}</div>
    </div>
  );
};


<CopilotKit>
  <CopilotSidebar AssistantMessage={CustomAssistantMessage} />
</CopilotKit>

子组件包括:

image.png

除此之外,markdown的渲染默认使用react-markdown。支持通过markdownTagRenderers属性来自定义markdown渲染组件。

Actions

允许 LLM 与应用程序的功能交互。当 LLM 调用 Action 时,可以提供自定义组件来可视化其执行和结果。

"use client" // only necessary if you are using Next.js with the App Router.
import { useCopilotAction } from "@copilotkit/react-core"; 

// Your custom components (examples - implement these in your app)
import { LoadingView } from "./loading-view"; // Your loading component
import { CalendarMeetingCardComponent, type CalendarMeetingCardProps } from "./calendar-meeting-card"; // Your meeting card component
 
export function YourComponent() {
  useCopilotAction({ 
    name: "showCalendarMeeting",
    description: "Displays calendar meeting information",
    parameters: [
      {
        name: "date",
        type: "string",
        description: "Meeting date (YYYY-MM-DD)",
        required: true
      },
      {
        name: "time",
        type: "string",
        description: "Meeting time (HH:mm)",
        required: true
      },
      {
        name: "meetingName",
        type: "string",
        description: "Name of the meeting",
        required: false
      }
    ],
    render: ({ status, args }) => {
      const { date, time, meetingName } = args;
 
      if (status === 'inProgress') {
        return <LoadingView />; // Your own component for loading state
      } else {
        const meetingProps: CalendarMeetingCardProps = {
          date: date,
          time,
          meetingName
        };
        return <CalendarMeetingCardComponent {...meetingProps} />;
      }
    },
  });
 
  return (
    <>...</>
  );
}

Agent State

Agent State组件允许可视化 CoAgents 的内部状态和进度。使用 CoAgents 时,可以提供自定义组件来呈现代理的状态。

"use client"; // only necessary if you are using Next.js with the App Router.
 
import { useCoAgentStateRender } from "@copilotkit/react-core";
import { Progress } from "./progress";

type AgentState = {
  logs: string[];
}

useCoAgentStateRender<AgentState>({
  name: "basic_agent",
  render: ({ state, nodeName, status }) => {
    if (!state.logs || state.logs.length === 0) {
      return null;
    }

    // Progress is a component we are omitting from this example for brevity.
    return <Progress logs={state.logs} />; 
  },
});

前端工具

前端工具指的是前端定义的函数可以被 agent 调用。当函数被 agent 调用时,函数在客户端执行,使agent可以直接访问前端环境,从而实现agent对UI的控制和人机交互。

前端工具可以轻松实现:

  1. 读取或修改 React 组件状态
  2. 访问浏览器 API,如 localStorage、sessionStorage 或 cookie
  3. 触发 UI 更新或动画
  4. 与第三方前端库交互
  5. 执行需要用户即时浏览上下文的操作

实现

1.创建前端工具

需要使用 useFrontendTool 钩子创建一个前端工具。

//page.tsx 
import { useFrontendTool } from "@copilotkit/react-core"

export function Page() {
  // ...

  useFrontendTool({
    name: "sayHello",
    description: "Say hello to the user",
    parameters: [
      {
        name: "name",
        type: "string",
        description: "The name of the user to say hello to",
        required: true,
      },
    ],
    handler: async ({ name }) => {
      alert(`Hello, ${name}!`);
    },
  });

  // ...
}

2.修改代理

安装 CopilotKit SDK npm install @copilotkit/sdk-js

要访问 CopilotKit 提供的前端工具,可以在代理的状态定义中继承 CopilotKitState :

import { Annotation } from "@langchain/langgraph";
import { CopilotKitStateAnnotation } from "@copilotkit/sdk-js/langgraph"; 

export const YourAgentStateAnnotation = Annotation.Root({
    yourAdditionalProperty: Annotation<string>,
    ...CopilotKitStateAnnotation.spec, 
});
export type YourAgentState = typeof YourAgentStateAnnotation.State;

之后,代理的状态将包括 copilotkit 属性,其中包含可以访问和调用的前端工具。

agent调用前端工具:

async function agentNode(state: YourAgentState, config: RunnableConfig): Promise<YourAgentState> {
    // Access the tools from the copilotkit property

    const tools = state.copilotkit?.actions; 
    const model = ChatOpenAI({ model: 'gpt-4o' }).bindTools(tools);

    // ...
}

Generative UI

用自定义 UI 组件实时呈现 agent 的状态、进度、输出和工具调用。

主要有三种方式使用生成式UI:

  1. 后端工具:通过生成式UI组件渲染工具的调用
  2. 前端工具:为agent提供前端工具来显示自定义组件并且驱动UI
  3. agent 状态:用自定义组件来显示agent的状态、进度和输出

后端工具

工具是 LLM 调用预定义的(通常是确定性函数)的一种方式。CopilotKit 允许在 UI 中将这些工具呈现为自定义组件,我们称之为生成式 UI。

简而言之就是允许我们向用户展示agent正在执行的操作,尤其是当我们调用工具时,允许完全自定义工具在聊天中的呈现方式。

//agent.ts
import { ChatOpenAI } from "@langchain/openai";
import { tool } from "@langchain/core/tools";
import { z } from "zod";

const get_weather = tool(
  (args) => {
    return `The weather for ${args.location} is 70 degrees.`;
  },
  {
    name: "get_weather",
    description: "Get the weather for a given location.",
    schema: z.object({
      location: z.string().describe("The location to get weather for"),
    }),
  }
);

async function chat_node(state: AgentState, config: RunnableConfig) {
  const model = new ChatOpenAI({ temperature: 0, model: "gpt-4o" });
  const modelWithTools = model.bindTools([get_weather]); 

  const response = await modelWithTools.invoke([
    new SystemMessage("You are a helpful assistant."),
    ...state.messages,
  ], config);

  // ...
}

前端通过useCopilotAction钩子在UI中呈现:

//page.tsx  
import { useRenderToolCall } from "@copilotkit/react-core"; 
// ...

const YourMainContent = () => {
  // ...
  useRenderToolCall({
    name: "get_weather",// 注意name保持一致
    render: ({status, args}) => {
      return (
        <p className="text-gray-500 mt-2">
          {status !== "complete" && "Calling weather API..."}
          {status === "complete" && `Called the weather API for ${args.location}.`}
        </p>
      );
    },
  });
  // ...
}

useDefaultTool 捕获所有没有专用渲染器的工具。

//page.tsx
import { useDefaultTool } from "@copilotkit/react-core"; 
// ...

const YourMainContent = () => {
  // ...
  useDefaultTool({
    render: ({ name, args, status, result }) => {
      return (
        <div style={{ color: "black" }}>
          <span>
            {status === "complete" ? "✓" : "⏳"}
            {name}
          </span>
          {status === "complete" && result && (
            <pre>{JSON.stringify(result, null, 2)}</pre>
          )}
        </div>
      );
    },
  });
  // ...
}

前端工具

定义 LangGraph agent 可以调用的客户端函数,执行完全在用户的浏览器中进行。当agent调用前端工具时,逻辑会在客户端运行,使agent可以直接访问前端环境。一般在agent需要和客户端交互时使用。

使用 useFrontendTool 钩子创建一个前端工具:

// page.tsx
import { useFrontendTool } from "@copilotkit/react-core"

export function Page() {
  // ...

  useFrontendTool({
    name: "sayHello",
    description: "Say hello to the user",
    parameters: [
      {
        name: "name",
        type: "string",
        description: "The name of the user to say hello to",
        required: true,
      },
    ],
    handler({ name }) {
      // Handler returns the result of the tool call
      return { currentURLPath: window.location.href, userName: name };
    },
    render: ({ args }) => {
      // Renders UI based on the data of the tool call
      return (
        <div>
          <h1>Hello, {args.name}!</h1>
          <h1>You're currently on {window.location.href}</h1>
        </div>
      );
    },
  });

  // ...
}

agent 访问前端工具:

//agent.ts
import { Annotation } from "@langchain/langgraph";
import { CopilotKitStateAnnotation } from "@copilotkit/sdk-js/langgraph"; 

export const YourAgentStateAnnotation = Annotation.Root({
    yourAdditionalProperty: Annotation<string>,
    ...CopilotKitStateAnnotation.spec, 
});
export type YourAgentState = typeof YourAgentStateAnnotation.State;

//现在,agent的状态将包括 copilotkit 属性,其中包含可以访问和调用的前端工具。
async function agentNode(state: YourAgentState, config: RunnableConfig): Promise<YourAgentState> {
    // Access the tools from the copilotkit property

    const tools = state.copilotkit?.actions; 
    const model = ChatOpenAI({ model: 'gpt-4o' }).bindTools(tools);

    // ...
}

agent 状态

所有 LangGraph agent 都是状态化的。这意味着当 agent 通过节点时,一个状态对象会在它们之间传递,以保持会话的整体状态。CopilotKit 允许使用自定义 UI 组件(生成式 UI)在应用程序中呈现此状态。

// agent.ts
import { RunnableConfig } from "@langchain/core/runnables";
import { ChatOpenAI } from "@langchain/openai";
import { Annotation } from "@langchain/langgraph";
import { SystemMessage } from "@langchain/core/messages";
import { copilotkitEmitState, CopilotKitStateAnnotation } from "@copilotkit/sdk-js/langgraph";

type Search = {
  query: string;
  done: boolean;
}

export const AgentStateAnnotation = Annotation.Root({
  searches: Annotation<Search[]>,
  ...CopilotKitStateAnnotation.spec,
});

async function chat_node(state: AgentState, config: RunnableConfig) {
  state.searches = [
    { query: "Initial research", done: false },
    { query: "Retrieving sources", done: false },
    { query: "Forming an answer", done: false },
  ];

  // We can call copilotkit_emit_state to emit updated state
  // before a node finishes
  await copilotkitEmitState(config, state);

  // Simulate state updates
  for (const search of state.searches) {
    await new Promise(resolve => setTimeout(resolve, 1000));
    search.done = true;

    // We can also emit updates in a loop to simulate progress
    await copilotkitEmitState(config, state);
  }

  const response = await new ChatOpenAI({ model: "gpt-4o" }).invoke([
    new SystemMessage({ content: "You are a helpful assistant."}),
    ...state.messages,
  ], config);

前端通过 useCoAgentStateRender 在聊天中呈现agent的状态:

//page.tsx
import { useCoAgentStateRender } from "@copilotkit/react-core";

// For type safety, redefine the state of the agent. If you're using
// using LangGraph JS you can import the type here and share it.
type AgentState = {
  searches: {
    query: string;
    done: boolean;
  }[];
};

function YourMainContent() {
  // ...

  // styles omitted for brevity
  useCoAgentStateRender<AgentState>({
    name: "sample_agent", // the name the agent is served as
    render: ({ state }) => (
      <div>
        {state.searches?.map((search, index) => (
          <div key={index}>
            {search.done ? "✅" : "❌"} {search.query}{search.done ? "" : "..."}
          </div>
        ))}
      </div>
    ),
  });

  // ...

  return <div>...</div>;
}

除此之外还允许在聊天之外呈现agent的状态:

//page.tsx
import { useCoAgent } from "@copilotkit/react-core"; 
// ...

// Define the state of the agent, should match the state of the agent in your LangGraph.
type AgentState = {
  searches: {
    query: string;
    done: boolean;
  }[];
};

function YourMainContent() {
  // ...

  const { state } = useCoAgent<AgentState>({
    name: "sample_agent", // the name the agent is served as
  })

  // ...

  return (
    <div>
      {/* ... */}
      <div className="flex flex-col gap-2 mt-4">
        {state.searches?.map((search, index) => (
          <div key={index} className="flex flex-row">
            {search.done ? "✅" : "❌"} {search.query}
          </div>
        ))}
      </div>
    </div>
  )
}

Human in the Loop (HITL)

用户与 agent 协作处理复杂任务。

主要有三种方式实现人机交互:

  1. 基于中断
  2. 基于前端工具
  3. 基于节点

基于中断

基础

需要一个状态属性来存储名称:

//agent.ts
// ...
import { Annotation } from "@langchain/langgraph";
import { CopilotKitStateAnnotation } from "@copilotkit/sdk-js/langgraph";
// ...

// This is the state of the agent.
// It inherits from the CopilotKitState properties from CopilotKit.
export const AgentStateAnnotation = Annotation.Root({
  agentName: Annotation<string>,
  ...CopilotKitStateAnnotation.spec,
});
export type AgentState = typeof AgentStateAnnotation.State;

在LangGraph 代理中调用 interrupt:

//agent.ts
import { interrupt } from "@langchain/langgraph"; 
import { SystemMessage } from "@langchain/core/messages";
import { ChatOpenAI } from "@langchain/openai";

// add the agent state definition from the previous step
export const AgentStateAnnotation = Annotation.Root({
    agentName: Annotation<string>,
    ...CopilotKitStateAnnotation.spec,
});
export type AgentState = typeof AgentStateAnnotation.State;

async function chat_node(state: AgentState, config: RunnableConfig) {
    const agentName = state.agentName
    ?? interrupt("Before we start, what would you like to call me?"); 

    // Tell the agent its name
    const systemMessage = new SystemMessage({
        content: `You are a helpful assistant named ${agentName}...`,
    });

    const response = await new ChatOpenAI({ model: "gpt-4o" }).invoke(
        [systemMessage, ...state.messages],
        config
    );

    return {
        ...state,
        agentName,
        messages: response,
    };
}

前端使用 useLangGraphInterrupt 钩子,为终中断提供一个要渲染的组件,然后使用用户的响应调用 resolve。

//page.tsx
import { useLangGraphInterrupt } from "@copilotkit/react-core"; 
// ...

const YourMainContent = () => {
// ...
// styles omitted for brevity
useLangGraphInterrupt({
    render: ({ event, resolve }) => (
        <div>
            <p>{event.value}</p>
            <form onSubmit={(e) => {
                e.preventDefault();
                resolve((e.target as HTMLFormElement).response.value);
            }}>
                <input type="text" name="response" placeholder="Enter your response" />
                <button type="submit">Submit</button>
            </form>
        </div>
    )
});
// ...

return <div>{/* ... */}</div>
}

高级

在代理中呈现多个中断事件时,UI 中的多个 useLangGraphInterrupt 钩子调用之间可能会发生冲突。出于这个原因,钩子可以接受一个 enabled 参数,该参数将有条件地应用它:

定义两个不同的中断,通过type进行区分:

//agent.ts
import { interrupt } from "@langchain/langgraph"; 
import { SystemMessage } from "@langchain/core/messages";
import { ChatOpenAI } from "@langchain/openai";

// ... your full state definition

async function chat_node(state: AgentState, config: RunnableConfig) {
  state.approval = await interrupt({ type: "approval", content: "please approve" }); 

  if (!state.agentName) {
    state.agentName = await interrupt({ type: "ask", content: "Before we start, what would you like to call me?" }); 
  }

  // Tell the agent its name
  const systemMessage = new SystemMessage({
    content: `You are a helpful assistant...`,
  });

  const response = await new ChatOpenAI({ model: "gpt-4o" }).invoke(
    [systemMessage, ...state.messages],
    config
  );

  return {
    ...state,
    messages: response,
  };
}

前端定义多个处理程序:

//page.tsx
import { useLangGraphInterrupt } from "@copilotkit/react-core"; 
// ...

const ApproveComponent = ({ content, onAnswer }: { content: string; onAnswer: (approved: boolean) => void }) => (
    // styles omitted for brevity
    <div>
        <h1>Do you approve?</h1>
        <button onClick={() => onAnswer(true)}>Approve</button>
        <button onClick={() => onAnswer(false)}>Reject</button>
    </div>
)

const AskComponent = ({ question, onAnswer }: { question: string; onAnswer: (answer: string) => void }) => (
// styles omitted for brevity
    <div>
        <p>{question}</p>
        <form onSubmit={(e) => {
            e.preventDefault();
            onAnswer((e.target as HTMLFormElement).response.value);
        }}>
            <input type="text" name="response" placeholder="Enter your response" />
            <button type="submit">Submit</button>
        </form>
    </div>
)

const YourMainContent = () => {
    // ...
    useLangGraphInterrupt({
        enabled: ({ eventValue }) => eventValue.type === 'ask',
        render: ({ event, resolve }) => (
            <AskComponent question={event.value.content} onAnswer={answer => resolve(answer)} />
        )
    });

    useLangGraphInterrupt({
        enabled: ({ eventValue }) => eventValue.type === 'approval',
        render: ({ event, resolve }) => (
            <ApproveComponent content={event.value.content} onAnswer={answer => resolve(answer)} />
        )
    });

    // ...
}

选择自定义聊天 UI 时,某些情况可能需要预处理中断事件的传入值,甚至需要完全解析它而不显示其 UI。这可以使用 handeer 属性来实现,该属性不需要返回一个 React 组件。 处理程序的返回值将作为结果参数传递给 render 方法。

//page.tsx
// We will assume an interrupt event in the following shape
type Department = 'finance' | 'engineering' | 'admin'
interface AuthorizationInterruptEvent {
    type: 'auth',
    accessDepartment: Department,
}

import { useLangGraphInterrupt } from "@copilotkit/react-core";

const YourMainContent = () => {
    const [userEmail, setUserEmail] = useState({ email: 'example@user.com' })
    function getUserByEmail(email: string): { id: string; department: Department } {
        // ... an implementation of user fetching
    }

    // ...
    // styles omitted for brevity
    useLangGraphInterrupt({
        handler: async ({ result, event, resolve }) => {
            const { department } = await getUserByEmail(userEmail)
            if (event.value.accessDepartment === department || department === 'admin') {
                // Following the resolution of the event, we will not proceed to the render method
                resolve({ code: 'AUTH_BY_DEPARTMENT' })
                return;
            }

            return { department, userId }
        },
        render: ({ result, event, resolve }) => (
            <div>
                <h1>Request for {event.value.type}</h1>
                <p>Members from {result.department} department cannot access this information</p>
                <p>You can request access from an administrator to continue.</p>
                <button
                    onClick={() => resolve({ code: 'REQUEST_AUTH', data: { department: result.department, userId: result.userId } })}
                >
                    Request Access
                </button>
                <button
                    onClick={() => resolve({ code: 'CANCEL' })}
                >
                    Cancel
                </button>
            </div>
        )
    });
    // ...

    return <div>{/* ... */}</div>
}

基于前端工具

前端工具可以用在多种方式上。其中一种方式是有人工介入的流程,工具的响应由用户的决定来控制。

在这个例子中,我们将模拟一个用于执行命令的“批准”流程。首先,使用useHumanInTheLoop钩子来创建一个提示用户批准的工具。

要访问由 CopilotKit 提供的前端工具,您可以在agent的状态定义中继承自 CopilotKitState:

//agent.ts
import { Annotation } from "@langchain/langgraph";
import { CopilotKitStateAnnotation } from "@copilotkit/sdk-js/langgraph"; 

export const YourAgentStateAnnotation = Annotation.Root({
    yourAdditionalProperty: Annotation<string>,
    ...CopilotKitStateAnnotation.spec, 
});
export type YourAgentState = typeof YourAgentStateAnnotation.State;

经上述操作,agent的状态将包括 copilotkit 属性,其中包含可以访问和调用的前端工具。接下来就可以调用前端工具方法:

//agent.tsx
async function agentNode(state: YourAgentState, config: RunnableConfig): Promise<YourAgentState> {
    // Access the tools from the copilotkit property

    const tools = state.copilotkit?.actions; 
    const model = ChatOpenAI({ model: 'gpt-4o' }).bindTools(tools);

    // ...
}

基于节点

LangGraph 和 CopilotKit 现在都不鼓励使用基于节点的中断。从 LangGraph 0.2.57 开始,推荐的设置断点的方法是使用 interrupt 函数 ,因为它简化了人机交互模式。

想要了解可以参考这里

状态共享

在 UI 和 LangGraph agent 状态之间创建双向连接。

读取agent状态

不仅可以在聊天界面中使用实时代理状态,还可以在原生应用程序中使用。

LangGraph 是有状态的。在节点之间转换时,该状态将更新并传递到下一个节点。假设agent状态如下所示:

//agent.ts
import { Annotation } from "@langchain/langgraph";
import { CopilotKitStateAnnotation } from "@copilotkit/sdk-js/langgraph";

export const AgentStateAnnotation = Annotation.Root({
    language: Annotation<"english" | "spanish">,
    ...CopilotKitStateAnnotation.spec,
});
export type AgentState = typeof AgentStateAnnotation.State;

async function chat_node(state: AgentState, config: RunnableConfig) {
  // If language is not defined, use a default value.
  const language = state.language ?? 'spanish'

  // ... add the rest of the node implementation and use the language variable

  return {
    // ... add the rest of state to return
    // return the language to make it available for the next nodes & frontend to read
    language
  }
}

前端调用 useCoAgent 钩子,传递agent的名称,并选择性地提供初始状态:

//pages.tsx
import { useCoAgent } from "@copilotkit/react-core"; 

// Define the agent state type, should match the actual state of your agent
type AgentState = {
  language: "english" | "spanish";
}

function YourMainContent() {
  const { state } = useCoAgent<AgentState>({
    name: "sample_agent",
    initialState: { language: "spanish" }  // optionally provide an initial state
  });

  // ...

  return (
    // style excluded for brevity
    <div>
      <h1>Your main content</h1>
      <p>Language: {state.language}</p> // [!code highlight]
    </div>
  );
}

useCoAgent 中的状态是响应式的,当代理的状态发生变化时会自动更新

在聊天中渲染 agent 状态

可以使用 useCoAgentStateRender 钩子。

//page.tsx
import { useCoAgentStateRender } from "@copilotkit/react-core"; 

// Define the agent state type, should match the actual state of your agent
type AgentState = {
  language: "english" | "spanish";
}

function YourMainContent() {
  // ...
  useCoAgentStateRender({
    name: "sample_agent",
    render: ({ state }) => {
      if (!state.language) return null;
      return <div>Language: {state.language}</div>;
    },
  });
  // ...
}

useCoAgentStateRender 中的状态是响应式的,当代理的状态发生变化时会自动更新。

写入agent状态

假设 agent 状态:

//agebt.ts
import { Annotation } from "@langchain/langgraph";
import { CopilotKitStateAnnotation } from "@copilotkit/sdk-js/langgraph";

export const AgentStateAnnotation = Annotation.Root({
    language: Annotation<"english" | "spanish">,
    ...CopilotKitStateAnnotation.spec,
});
export type AgentState = typeof AgentStateAnnotation.State;

useCoAgent 返回一个 setState 函数,您可以使用该函数来更新代理状态。调用这个将更新代理状态并触发依赖于代理状态的任何内容的重新渲染。

//page.tsx
import { useCoAgent } from "@copilotkit/react-core"; 

// Define the agent state type, should match the actual state of your agent
type AgentState = {
  language: "english" | "spanish";
}

// Example usage in a pseudo React component
function YourMainContent() {
  const { state, setState } = useCoAgent<AgentState>({ 
    name: "sample_agent",
    initialState: { language: "spanish" }  // optionally provide an initial state
  });

  // ...

  const toggleLanguage = () => {
    setState({ language: state.language === "english" ? "spanish" : "english" }); 
  };

  // ...

  return (
    // style excluded for brevity
    <div>
      <h1>Your main content</h1>
      <p>Language: {state.language}</p>
      <button onClick={toggleLanguage}>Toggle Language</button>
    </div>
  );
}

高级用法

重新运行agent,并提示更改内容

新的 agent 状态将在下次 agent 运行时使用。如果您想手动重新运行它,请在useCoAgent钩子上使用run参数。 agent将重新运行,它不仅会获得最新的更新状态,还会获得可能取决于以前状态和当前状态之间的数据增量的提示 。

//page.tsx
import { useCoAgent } from "@copilotkit/react-core";
import { TextMessage, MessageRole } from "@copilotkit/runtime-client-gql";  

// ...

function YourMainContent() {
  const { state, setState, run } = useCoAgent<AgentState>({
    name: "sample_agent",
    initialState: { language: "spanish" }  // optionally provide an initial state
  });

  // setup to be called when some event in the app occurs
  const toggleLanguage = () => {
    const newLanguage = state.language === "english" ? "spanish" : "english";
    setState({ language: newLanguage });

    // re-run the agent and provide a hint about what's changed
    run(({ previousState, currentState }) => {
      return new TextMessage({
        role: MessageRole.User,
        content: `the language has been updated to ${currentState.language}`,
      });
    });
  };

  return (
    // ...
  );
}

agent状态输入和输出

有些属性是内部处理的,而另一些则是 UI 传递用户输入的方式。此外,有些状态属性包含大量信息。在agent和 UI 之间来回同步它们可能成本高昂,而且可能没有实际好处。

假设状态如下:

//agent.ts
import { Annotation } from "@langchain/langgraph";
import { CopilotKitStateAnnotation } from "@copilotkit/sdk-js/langgraph";

const AgentState = Annotation.Root({
  ...CopilotKitStateAnnotation.spec,
  question: Annotation<string>, //期望LLM回答的问题
  answer: Annotation<string>,//LLM回应的答案
  resources: Annotation<string[]>,//用于LLM回答问题,不应向用户同步
})

agent定义输入输出:

//agent.ts
  import { Annotation } from "@langchain/langgraph";
  import { CopilotKitStateAnnotation } from "@copilotkit/sdk-js/langgraph";

  // Divide the state to 3 parts

  // An input schema for inputs you are willing to accept from the frontend
  const InputAnnotation = Annotation.Root({
    ...CopilotKitStateAnnotation.spec,
    question: Annotation<string>,
  });

  // Output schema for output you are willing to pass to the frontend
  const OutputAnnotation = Annotation.Root({
    ...CopilotKitStateAnnotation.spec,
    answer: Annotation<string>,
  });

  // The full schema, including the inputs, outputs and internal state ("resources" in our case)
  export const AgentStateAnnotation = Annotation.Root({
    ...CopilotKitStateAnnotation.spec,
    ...OutputAnnotation.spec,
    ...InputAnnotation.spec,
    resources: Annotation<string[]>,
  });

  // Define a typed state that supports the entire
  export type AgentState = typeof AgentStateAnnotation.State;

  async function answerNode(state: AgentState, config: RunnableConfig) {
    const model = new ChatOpenAI()

    const systemMessage = new SystemMessage({
      content: `You are a helpful assistant. Answer the question: ${state.question}.`,
    });

    const response = await modelWithTools.invoke(
      [systemMessage, ...state.messages],
      config
    );

    // ...add the rest of the agent implementation
    // extract the answer, which will be assigned to the state soon
    const answer = response.content

    return {
      messages: response,
      // include the answer in the returned state
      answer,
    }
  }

  // finally, before compiling the graph, we define the 3 state components
  const workflow = new StateGraph({
    input: InputAnnotation,
    output: OutputAnnotation,
    // @ts-expect-error -- LangGraph does not expect a "full schema with internal properties".
    stateSchema: AgentStateAnnotation,
  })
    .addNode("answer_node", answerNode) // add all the different nodes and edges and compile the graph
    .addEdge(START, "answer_node")
    .addEdge("answer_node", END)
  export const graph = workflow.compile()

预测状态更新

一个LangGraph agent的状态更新是不连续的;仅在图中的节点转换之间进行。但是,图中单个节点运行往往需要许多秒,并且包含用户感兴趣的子步骤。

预测状态更新帮助我们向用户尽可能连续的反应 agent 正在执行的操作。

当你的LangGraph中的节点执行完毕时,其返回的状态成为唯一的真实来源。虽然中间状态更新对于实时反馈很有帮助,但任何你想持久化的更改都必须明确地包含在节点的最终返回状态中。否则,它们将在节点完成时被覆盖。

在状态中定义一个 observed_steps 字段,该字段将在agent编写报告的不同部分时更新。

//agent.ts
import { Annotation } from "@langchain/langgraph";
import { CopilotKitStateAnnotation } from "@copilotkit/sdk-js/langgraph";

export const AgentStateAnnotation = Annotation.Root({
    observed_steps: Annotation<string[]>,  // Array of completed steps
    ...CopilotKitStateAnnotation.spec,
});
export type AgentState = typeof AgentStateAnnotation.State;

有两种发出状态更新的方式:手动预测和基于工具的预测。

手动预测状态更新

对于长时间运行的任务,可以逐步发出状态更新,作为最终状态的预测。在这个例子中,我们通过执行一系列带有每次更新之间一秒延迟的步骤来模拟一个长时间运行的任务。

//agent.ts
import { copilotkitEmitState } from "@copilotkit/sdk-js/langgraph"; 
// ...
async function chat_node(state: AgentState, config: RunnableConfig) {
    // ...

    // Simulate executing steps one by one
    const steps = [
        "Analyzing input data...",
        "Identifying key patterns...",
        "Generating recommendations...",
        "Formatting final output..."
    ];
    
    for (const step of steps) {
        state.observed_steps = [...(state.observed_steps ?? []), step];
        copilotkitEmitState(config, state);
        await new Promise(resolve => setTimeout(resolve, 1000));
    }
}

这些预测将在代理运行时发出,允许您在确定最终状态之前跟踪其进度:

//page.tsx
import { useCoAgent, useCoAgentStateRender } from '@copilotkit/react-core';

// ...
type AgentState = {
    observed_steps: string[];
};

const YourMainContent = () => {
    // Get access to both predicted and final states
    const { state } = useCoAgent<AgentState>({ name: "sample_agent" });

    // Add a state renderer to observe predictions
    useCoAgentStateRender({
        name: "sample_agent",
        render: ({ state }) => {
            if (!state.observed_steps?.length) return null;
            return (
                <div>
                    <h3>Current Progress:</h3>
                    <ul>
                        {state.observed_steps.map((step, i) => (
                            <li key={i}>{step}</li>
                        ))}
                    </ul>
                </div>
            );
        },
    });

    return (
        <div>
            <h1>Agent Progress</h1>
            {state.observed_steps?.length > 0 && (
                <div>
                    <h3>Final Steps:</h3>
                    <ul>
                        {state.observed_steps.map((step, i) => (
                            <li key={i}>{step}</li>
                        ))}
                    </ul>
                </div>
            )}
        </div>
    )
}

基于工具预测状态更新

对于长时间运行的任务,您可以配置 CopilotKit 在执行特定工具调用时自动预测状态更新。在这个示例中,我们将配置 CopilotKit 在 LLM 调用步骤进度工具时预测状态更新。

//agent.ts
import { copilotkitCustomizeConfig } from '@copilotkit/sdk-js/langgraph';

async function frontendActionsNode(state: AgentState, config: RunnableConfig): Promise<AgentState> {
    const modifiedConfig = copilotkitCustomizeConfig(config, {
        emitIntermediateState: [
        {
            stateKey: "observed_steps",
            tool: "StepProgressTool",
            toolArgument: "steps",
        },
        ],
    });

    const stepProgress = tool(
        async (args) => args,
        {
            name: "StepProgressTool",
            description: "Records progress by updating the steps array",
            schema: z.object({
                steps: z.array(z.string()),
            }),
        }
    );

    const model = new ChatOpenAI({
        model: "gpt-4o",
    }).bindTools([stepProgress]);

    const system_message = new SystemMessage("You are a task performer. Pretend doing tasks you are given, report the steps using StepProgressTool.")
    const response = await model.invoke([system_message, ...state.messages], modifiedConfig);


    if (response.tool_calls?.length) {
        return {
            messages: response;
            observed_steps: response.tool_calls[0].args.steps,
        }

    return { messages: response };
}

这些预测将在代理运行时发出,允许您在确定最终状态之前跟踪其进度:

//page.tsx
import { useCoAgent, useCoAgentStateRender } from '@copilotkit/react-core';

// ...
type AgentState = {
    observed_steps: string[];
};

const YourMainContent = () => {
    // Get access to both predicted and final states
    const { state } = useCoAgent<AgentState>({ name: "sample_agent" });

    // Add a state renderer to observe predictions
    useCoAgentStateRender({
        name: "sample_agent",
        render: ({ state }) => {
            if (!state.observed_steps?.length) return null;
            return (
                <div>
                    <h3>Current Progress:</h3>
                    <ul>
                        {state.observed_steps.map((step, i) => (
                            <li key={i}>{step}</li>
                        ))}
                    </ul>
                </div>
            );
        },
    });

    return (
        <div>
            <h1>Agent Progress</h1>
            {state.observed_steps?.length > 0 && (
                <div>
                    <h3>Final Steps:</h3>
                    <ul>
                        {state.observed_steps.map((step, i) => (
                            <li key={i}>{step}</li>
                        ))}
                    </ul>
                </div>
            )}
        </div>
    )
}

子图

一个子图简单地是作为另一个图中的节点使用的图。把它看作是LangGraph的封装:每个子图是一个自包含的单元,可以组合起来构建更大、更复杂的系统。

使用此功能不需要在agent端执行额外步骤。您需要做的就是在 useCoAgent 钩子中启用子图流:

  const { state, nodeName } = useCoAgent<AgentState>({
    name: "sample_agent",
    initialState: INITIAL_STATE,
    config: {
      streamSubgraphs: true, 
    }
  });

子图节点将照常流式传输,你将能够使用 nodeName 变量查看哪个子图正在流式传输。您也可以直接从子图使用 interrupt()

深入 Nestjs 底层概念(1):依赖注入和面向切面编程 AOP

前言

本文保证绝对大白话!简单易懂!

最近跟一个好友讨论,他说想学习 node.js 后端框架 nest.js,但对于 nest.js 的下面用法一头雾水,为什么要这么用呢?什么 Contoller, Provider,怎么跟一般的 javascript 代码的用法完全不一样呢?如下的 @Controller,@Injectable 你理解是什么意思吗?为什么要用这样的方式组织代码呢?

import { Controller, Get } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';


@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }
}

@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: Cat) {
    this.cats.push(cat);
  }

  findAll(): Cat[] {
    return this.cats;
  }
}

基于此,我写了这篇文章,帮助大家从 0 开始,层层递进到所有这些让你陌生的 nest.js 的概念!

从编程范式说起

编程范式在之前面试经常被问到了,什么 FP(函数式编程),RP(响应式编程),FRP(函数式响应式编程),还有我们熟知的大兄弟 面相对象编程,现在 nest.js 又来个 AOP(面相切面编程),属实让人蒙圈了。。。。太多概念了。

别急,我们一个一个来!

函数式编程 - FP

简单来说就是要求你使用纯函数,什么是纯函数,就是输入一定的时候输出一定,就是传入相同的参数得到相同的结果。就这么简单,例如

function add(a, b) {
    sum = a + b;
    return sum;
}

常见有几种情况会影响纯函数的纯净,最常见就是:

  • 修改传参的值,或者引用,或者全局状态,改 DOM,例如
function add(a, b, c) { 
     // 假设 c 传入一个对象,c 的 xx 属性被修改
     c.xx = a + b; // 修改传参的值
     window.name = a; // 修改全局变量
    sum = a + b;
    return sum;
}
  • 做一些 i/o 操作,例如 console.log, 写文件。好了接下里说说 RP(响应式编程)

响应式编程 - RP

也能很好理解,我们举个例子:

就像“Excel 表格”:
你改了 A1,B1 = A1 + 10 会自动更新,这就是典型的 RP。

在 vue 和 react 都非常常见,跟踪一个值变化,然后引发另一些值变化。

它强调:

  • 数据是“流”(stream)
  • 数据变化会自动传播到依赖它的逻辑(自动更新)
  • 你不需要手动触发

函数式响应式编程 - FRP

FRP 是“函数式编程 + 响应式编程”的结合。比较典型的就是 Rxjs 的风格:

// 监听 DOM 的 input 事件
const input$ = fromEvent(searchInput, 'input'); // 输入是一个“流”

const results$ = input$.pipe(
  map(event => event.target.value),  // 函数式转换
);

results$.subscribe(renderResults);

简单理解就是当触发 DOMinput 事件触发的时候,也就是一个值改变的时候,触发 results$ 值的改变,这就是 RP(响应式编程),然后在 input 事件触发值改变的过程中,还执行了 map 方法,这是一个纯函数,所以也算 FP 函数式编程。

OOP - 面向对象编程

顾名思义,面相对象就是以对象为对象为单位,例如

class Dog {
  name: string;

  constructor(name: string) {
    this.name = name;
  }

  bark() {
    console.log(`${this.name} is barking!`);
  }
}

使用到的数据,最好都封装为对象,如上面,使用的时候需要 new Dog() 返回我们新创造出来的实例,也就是 class 仅仅是个模板。对象是由模板创造出来的。

然后就是面向对象的特点,也就是最熟悉的三件套,封装,继承,多态。靠这三个,面向对象编程的思维模式认为可以模拟现实世界。

封装:很简单,例如 Dog 这个类,把跟 Dog 相关的属性和方法都放在一个类里就是封装,例如有 name 属性,狗的名字,你如果想加其它参数,继续拓展就行了,例如性别,品种。当然封装还有一些细节,例如封装私有属性什么的,这些细枝末节不用太在意。我们主要是简单介绍 OOP 的思想。

继承:可以将别的类的东西继承过来,例如, 很多动物都可以继承Animal 这个类:

class Animal {
  constructor(public name: string) {}

  makeSound() {
    console.log("Some sound...");
  }
}

class Cat extends Animal {
  makeSound() {
    console.log(`${this.name} says meow~`);
  }
}

class Dog extends Animal {
  makeSound() {
    console.log(`${this.name} says woof!`);
  }
}

const cat = new Cat("Mimi");
const dog = new Dog("Coco");

cat.makeSound(); // Mimi says meow~
dog.makeSound(); // Coco says woof!

主要是为我们抽象代码用的,例如公共部分放到一个类中,子类单独实现。思想是挺好的,就是实现起来有时候会比较麻烦,因为刚开始设计的时候,不太容易一开始就能预知未来需要抽象哪些。

多态:简单来说就是一个接口,不同实现,Javascript 中没有多态,更多在 typescript 中出现,例如用重载实现多态。如下:

class Calculator {
  add(a: number, b: number): number;
  add(a: string, b: string): string;
  add(a: any, b: any): any {
    return a + b;
  }
}

const calc = new Calculator();

console.log(calc.add(5, 10));       // 15
console.log(calc.add("foo", "bar")) // foobar

面相切面编程 - AOP

为我们提供了一种将代码注入现有函数或对象的方法,而无需修改目标逻辑。这句话大家看看就行了,很官方,我们简单理解就是:

把横跨多个功能的“通用逻辑”抽出来,统一管理,而不是在每个函数里重复写。

常见实现的方式,就是在函数前后,注册一个钩子,这样达到不修改当前函数逻辑的目的。例如:

function logBefore(fn) {
  return function (...args) {
    console.log("Calling:", fn.name, "args:", args);
    return fn.apply(this, args);
  }
}

以上方法是一个 切面 ,也就是可以包装到另一个函数上,在这个函数调用之前打印日志,是一个独立的功能。例如:

const getUser = logBefore(function getUser(id) {
  // 在数据库搜索数据返回
  return db.query(`SELECT * FROM users WHERE id = ${id}`);
});

这样其它函数都可以共享这个 logBefore 的逻辑。

当然实际场景,例如鉴权,在调用一个方法前,看看有没有权限,就是一个很好的使用 AOP 的场景。

AOP 跟 nest.js 关系

nest.js 后面我们会讲一个很关键的概念,pipeline,我们这里先简单介绍一下,后面详细说。pipeline 就是当客户端收到请求到返回的过程。

首先 nest.js 需要注册一个 Controller 来处理请求,如下

import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }
}

一般情况下,get 方法访问路径 /cats 的时候,返回 'This action returns all cats'。

但实际上到 Controller 处理数据之前,还有许多步骤。

例如在 nest.js 中有 Middleware(中间件) 的概念,也有 Guard(守卫的概念) 等等概念,我们这里不细说,后面文章会有,这里只需要记住:请求来了之后,先到 Middleware 然后到 Guard。。。兜兜转转好几回才能到 Controller

之前我们提到,AOP 中的面向切面,一个 切面 ,也就是可以包装到另一个函数上,在这个函数调用之前打印日志,是一个独立的功能。

nest.js 中的 Middleware 可以打印日志,是不是在不直接影响 Controller 逻辑的前提下,实现了一个可以复用的独立的功能,因为所有路由接收的请求都会经过 Middleware

从这个意义上说 MiddlewareGuard 这些概念本质就是 “切面”。

如果你对node.js ,组件库,前端动画感兴趣,欢迎进群交流。这是我组件库教程官网。感谢star,再次感谢:

依赖注入和控制反转

这两个概念看起来挺唬人的,比较高大上。我们简单解释一下:

我们从一个简单例子说起:

假设有一个类叫老王,专指那些住在美女隔壁的靓仔们。他们喜欢助人为乐,送大帽子。能力上擅长逃跑。

class Hobby {
    constructor(gender){
        return [gender, '助人为乐']
    }
}
class Skill {
    constructor(){
        return ['送帽子', '跑路']
    }
}
class Person {
    hobby: Hobby
    skill: Skill
    constructor(){
        this.hobby = new Hobby('女');
        this.skill = new Skill();
    }
}
console.log(new Person()); // { hobby: ['女', '助人为乐'], skill: ['送帽子', '跑路'] }

好了,这个Person类,我们看看有啥缺点:

  • 每次创建Person类的实例,都要传入Hobby类和Skill类,也就是对这两个类都产生了依赖,假如有一天我们想创建不同的老王,比如有的老王喜欢小鲜肉,有的老王喜欢老腊肉,这个Person类写死了,没法定制

有同学马上就会说,这个简单啊,Hobby和Skill当做参数传入不就行了,是的,这个方法确实能解决问题,如下:

class Hobby {
    constructor(gender){
        return [gender, '助人为乐']
    }
}
class Skill {
    constructor(){
        return ['送帽子', '跑路']
    }
}
class Person {
    hobby: Hobby
    skill: Skill
    constructor(hobby, skill){
        this.hobby = hobby;
        this.skill = skill;
    }
}

// { hobby: [’男', '助人为乐'], skill: ['送帽子', '跑路'] }
console.log(new Person(new Hobby('男'), new Skill()); 
  • 但有没有更好的办法,也就是这个类我们不用自己去new,像下面这样,系统帮我们自动导入呢?

class Person {
    constructor(hobby: Hobby, skill: Skill){
  
    }
    hello(){
        这里直接就可以用this.hobby了
    }
}

也就是说,在你new Person的时候,hobby和skill参数,自动帮你实例化导入,而且你还需要new Person,能不能不new Person 都是自动调用并把参数导入?

  • 这就引申出第一个概念就控制反转,以前我们都要自己主动去new,从而创建实例,现在是把创建实例的任务交给了一个容器(后面会实现,你就明白了,相当于一个掌控者,或者说造物主,专门来创建实例的,并管理依赖关系),所以控制权是不是就反转了,你主动的创建实例和控制依赖,反转为容器创建实例和控制依赖。
  • 对于控制反转,最常用件的方式叫依赖注入,意思是容器动态的将依赖关系注入到组件中。
  • 控制反转可以通过依赖注入实现,所以本质上是一回事。

到这里,其实大家也没觉得依赖注入有啥毛用吧!下面的讲解一言以蔽之就是,虽然我们js可以把函数或者类当参数传入另一个类里,这确实解决了之前讲的写死代码的问题,也就是说我们不用依赖注入也能解决代码耦合的问题,所以看起来我们并不是那么需要依赖注入。

小结

这里第一篇 nest.js 内容完毕,接下来会详细解释上文提到的 pipline,也就是一个请求经过 nest.js 的全过程,以及这些经过的过程中,碰到的核心概念!

🍭🍭🍭升级 AntD 6:做第一个吃螃蟹的人

AntD 6 发布之后,网上很多人都在观望:
“要不要升级?”
“会不会炸?”
“我的项目能不能扛得住?”

其实 AntD 官方已经在文档里把升级路径写得非常清楚,只是稍显简略。
下面我用更真实、更工程化的方式,把 v5 → v6 的升级步骤 做了一次加强版讲解,
让你升级时不至于踩坑。


① 第一步:升级到 v5 最新版本(必须执行)

在升级到 AntD 6 之前,官方强烈建议你先把项目从 v5 升到 v5 最新版本:5.29.1

为什么?

✔ v5 的最新版本会给出所有废弃 API 的 warning
✔ 不处理这些 warning,到 v6 会直接报错
✔ v5 → v6 是平滑升级路径,只要你处理掉 v5 的 warning,升级 v6 就不会炸

执行命令:

npm install antd@5

装完以后,启动项目,务必一条一条看控制台 warning

比如:

  • 某个 API 将被移除
  • 某个 props 已废弃
  • 某个组件 v6 即将删除

所有 warning 都处理完,再继续下一步。
这阶段非常关键,等于是在做“升级前全身检查”。

其实你只要用了5的版本基本没啥大问题

img_v3_02sd_59f552fe-40b4-4de6-be9c-bbe825512ahu.jpg


② 第二步:确保项目运行在 React 18(或以上)

AntD 6 不再支持 React 17 及以下版本
AntD 官方的态度非常明确:

“React 17,我们不救了。”

好消息是:绝大多数前端项目早就 React 18 了。
如果你还停留在 React 17,那建议你别升级 AntD,
你升级 React 本身都要做好打仗的准备

检查你的 package.json:

"react": "^18.x.x",
"react-dom": "^18.x.x"

如果不是,那你真的得升级个 der(官方术语:赶紧升 😅)。
React 17 升到 React 18 已经是必经之路,
Suspense、Concurrent、SSR 都已经进入新阶段,不升会拖累整个项目生态。


③ 第三步:开干!升级到 AntD 6

前面两步做完,你的项目基本已经“具备上 6 条件”了。
现在就可以正式开刀:

npm install --save antd@6

或者你爱用的包管理器:

yarn add antd@6
# or
pnpm add antd@6
# or
npm install antd@latest

安装完成后,你的项目就是 AntD 6 正式用户


④ 第四步:启动项目,处理残留 warning

升级完成以后,重启项目。

你可能会看到一些:

  • 类型定义变动 warning
  • 某些行为变更 warning
  • 某些组件结构调整提示
  • mask blur 带来的视觉差异
  • 你自己写的样式被 DOM 改动影响

这些属于正常“升级后适配”。
根据提示处理即可。

建议你重点检查以下区域:

✔ 自定义覆盖类名(AntD 6 DOM 有变化)
✔ Modal、Drawer 的 mask 是否出现模糊效果
✔ Table、Form 是否有类型冲突
✔ 已废弃 API 是否仍然使用
✔ 第三方依赖是否引用了 AntD 内部 class

通常处理 1-2 小时就可以全部解决 ( 不解决也行,能跑就行😉)。


⑤ 最终:你就正式吃上了 AntD 6 的“螃蟹”

完成以上步骤后,你就完成了整个升级链路:

  • 清理 v5 废弃 API
  • 升到 React 18(如果你的项目还没)
  • 升到 AntD 6
  • 修复升级后剩余 warning

从此以后:

  • 你可以用 Masonry 瀑布流
  • 你可以用更快的 Tooltip
  • 你可以享受 ZeroRuntime
  • 你可以用语义化 DOM 更好写主题
  • 你的项目正式进入 2025 年的前端栈

一句话:
你是第一个吃螃蟹的人,但这次螃蟹真的不难吃,而且还挺香,哥们已经升级,满嘴流油了。

Suggestion.gif

flutter睡眠与冥想数据可视化神器:sleep_stage_chart插件全解析

在健康类 App 开发中,睡眠周期分析和冥想数据展示是核心功能模块。一个直观、美观且交互流畅的可视化图表,能极大提升用户对健康数据的理解和使用体验。今天给大家推荐一款专为 Flutter 开发者打造的全能型图表插件——sleep_stage_chart,它不仅能完美呈现睡眠阶段数据,还支持冥想时长可视化,跨平台兼容且高度可定制。

1. 简介

sleep_stage_chart是一款专注于睡眠阶段和冥想数据可视化的 Flutter 插件,借鉴了 Apple Health 应用的优雅设计风格,提供了平滑的过渡效果和丰富的交互能力。该插件支持 Android、iOS 和 Windows 三大平台,能够满足健康类 App 对睡眠周期分析、冥想时长统计等场景的可视化需求。

1.1. 例图

睡眠图

冥想图

1.2. 核心功能

该插件的功能覆盖了健康数据可视化的核心需求,同时提供了足够的灵活性:

  • 📊 双图表支持:同时兼容睡眠阶段图(浅睡/深睡/REM/清醒状态)和冥想时长图
  • 🎨 深度定制化:支持颜色、样式、布局、网格线等全维度自定义
  • 📱 跨平台兼容:无缝运行于 Android、iOS、Windows 平台
  • 🤏 交互体验:支持触摸拖拽指示器,查看不同时段的详细数据
  • 🕐 精准时间展示:清晰呈现时间范围、阶段时长,支持自定义时间格式化
  • 🎀 样式扩展:支持自定义底部组件、圆角、背景色等外观属性
  • 📖 完善文档:提供完整的 API 说明和可直接运行的示例代码

2. 快速集成

在项目的 pubspec.yaml 文件中添加插件依赖:

dependencies:
  sleep_stage_chart: ^1.1.2  # 建议使用最新版本

执行安装命令:

flutter pub get

2.1. 基础使用示例

插件提供了两种核心图表场景:睡眠阶段图和冥想时长图,以下是最简实现示例。

睡眠阶段图

import 'package:flutter/material.dart';
import 'package:sleep_stage_chart/sleep_stage_chart.dart';

class SleepChartDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 构造睡眠数据:包含阶段类型、起止时间、描述信息
    final sleepData = [
      SleepStageDetails(
        model: SleepStageEnum.light,  // 浅睡
        startTime: DateTime(2025, 1, 1, 22, 30),
        endTime: DateTime(2025, 1, 1, 23, 30),
        info: ['浅睡,睡眠质量良好'],
      ),
      SleepStageDetails(
        model: SleepStageEnum.deep,   // 深睡
        startTime: DateTime(2025, 1, 1, 23, 30),
        endTime: DateTime(2025, 1, 2, 1, 0),
        info: ['深睡,身体修复阶段'],
      ),
      SleepStageDetails(
        model: SleepStageEnum.rem,    // REM睡眠(快速眼动)
        startTime: DateTime(2025, 1, 2, 1, 0),
        endTime: DateTime(2025, 1, 2, 2, 15),
        info: ['REM睡眠,大脑活跃'],
      ),
      SleepStageDetails(
        model: SleepStageEnum.awake,  // 清醒
        startTime: DateTime(2025, 1, 2, 6, 0),
        endTime: DateTime(2025, 1, 2, 6, 30),
        info: ['清醒,准备起床'],
      ),
    ];

    return Container(
      height: 300,
      margin: const EdgeInsets.all(16),
      child: SleepStageChart(
        details: sleepData,  // 睡眠数据(必填)
        startTime: DateTime(2025, 1, 1, 22, 30),  // 开始时间(必填)
        endTime: DateTime(2025, 1, 2, 6, 30),     // 结束时间(必填)
        backgroundColor: Colors.white,            // 背景色(必填)
        heightUnitRatio: 1 / 8,                   // 高度比例单位
        onIndicatorMoved: (stage) {               // 指示器移动回调
          print('当前阶段:${stage.model.name},时长:${stage.duration}分钟');
        },
        bottomChild: const [Text('入睡'), Text('起床')],  // 底部自定义文本
        stageColors: {  // 自定义各阶段颜色
          SleepStageEnum.light: Colors.blue.shade300,
          SleepStageEnum.deep: Colors.blue.shade700,
          SleepStageEnum.rem: Colors.teal.shade400,
          SleepStageEnum.awake: Colors.orange.shade300,
        },
      ),
    );
  }
}

冥想时长图

冥想图表通常需要展示全天或特定时段的冥想分布,可通过统一颜色和时间轴配置实现:

import 'package:flutter/material.dart';
import 'package:sleep_stage_chart/sleep_stage_chart.dart';

class MeditationChartDemo extends StatelessWidget {
  final DateTime dayStart = DateTime(2025, 1, 1);

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 300,
      margin: const EdgeInsets.all(16),
      child: SleepStageChart(
        details: [
          SleepStageDetails(
            model: SleepStageEnum.light,
            startTime: dayStart.add(const Duration(minutes: 30)),
            endTime: dayStart.add(const Duration(minutes: 75)),
            info: ['晨间冥想,专注呼吸'],
          ),
          SleepStageDetails(
            model: SleepStageEnum.light,
            startTime: dayStart.add(const Duration(hours: 19)),
            endTime: dayStart.add(const Duration(hours: 20, minutes: 20)),
            info: ['睡前冥想,放松身心'],
          ),
        ],
        startTime: dayStart,
        endTime: dayStart.add(const Duration(days: 1)),
        backgroundColor: Colors.transparent,
        heightUnitRatio: 1 / 8,
        allDayModel: true,  // 开启全天模式
        minuteInterval: 360,  // 时间轴间隔:6小时
        stageColors: const {  // 统一冥想颜色
          SleepStageEnum.light: Color(0xFF43CAC4),
          SleepStageEnum.deep: Color(0xFF43CAC4),
          SleepStageEnum.rem: Color(0xFF43CAC4),
          SleepStageEnum.awake: Color(0xFF43CAC4),
        },
        bottomChild: ['00:00', '06:00', '12:00', '18:00', '00:00']
            .map((time) => Text(time, style: const TextStyle(fontSize: 12)))
            .toList(),
        showVerticalLine: true,  // 显示竖线分隔
        showHorizontalLine: false,  // 隐藏横线
        borderRadius: 12,  // 圆角优化
      ),
    );
  }
}

2.2. 高级定制指南

sleep_stage_chart提供了丰富的定制属性,以下是常见场景的定制方案。

颜色定制

通过 stageColors 属性自定义各睡眠阶段的颜色,支持所有 SleepStageEnum 类型:

stageColors: const {
  SleepStageEnum.light: Color(0xFFE3F2FD),  // 浅睡-淡蓝
  SleepStageEnum.deep: Color(0xFF90CAF9),   // 深睡-中蓝
  SleepStageEnum.rem: Color(0xFF42A5F5),    // REM-深蓝
  SleepStageEnum.awake: Color(0xFFFFE0B2),  // 清醒-淡橙
  SleepStageEnum.notWorn: Color(0xFFF5F5F5),// 未佩戴-灰色
  SleepStageEnum.unknown: Color(0xFFEEEEEE),// 未知-浅灰
},

网格线定制

控制网格线的显示/隐藏和样式:

// 横线样式
horizontalLineStyle: SleepStageChartLineStyle(
  width: 1.0,
  color: Colors.grey.shade200,
  space: 2.0,  // 虚线间隔
),
// 竖线样式
verticalLineStyle: SleepStageChartLineStyle(
  width: 1.0,
  color: Colors.grey.shade200,
),
showHorizontalLine: true,  // 显示横线
showVerticalLine: true,    // 显示竖线
horizontalLineCount: 6,    // 横线数量(分割图表高度)

时间格式化

通过 dateFormatter 自定义时间轴的显示格式:

dateFormatter: (DateTime date) {
  // 自定义格式:小时-分钟(补零)
  return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
},

交互控制

控制指示器的显示和回调:

hasIndicator: true,  // 显示触摸指示器
onIndicatorMoved: (SleepStageDetails stage) {
  // 指示器移动时回调,可用于更新详情面板
  setState(() {
    _currentStage = stage;
    _currentDuration = '${stage.duration}分钟';
    _currentInfo = stage.info.join(' ');
  });
},

3. 核心 API 参考

下面是核心的api属性列举:

SleepStageChart(主组件)

属性名 类型 默认值 描述
details List - 核心数据(必填)
startTime DateTime - 开始时间(必填)
endTime DateTime - 结束时间(必填)
backgroundColor Color - 背景色(必填)
stageColors Map<SleepStageEnum, Color>? null 阶段颜色映射
heightUnitRatio double - 高度比例单位
borderRadius double 8.0 圆角半径
showVerticalLine bool true 是否显示竖线
showHorizontalLine bool true 是否显示横线
hasIndicator bool true 是否显示触摸指示器
onIndicatorMoved void Function(SleepStageDetails)? null 指示器移动回调
allDayModel bool false 是否开启全天模式
minuteInterval int 360 全天模式时间间隔(分钟)
bottomChild List [] 底部自定义组件列表
dateFormatter String Function(DateTime)? null 时间格式化函数

SleepStageDetails(数据模型)

属性名 类型 描述
model SleepStageEnum 睡眠/冥想阶段类型
startTime DateTime 阶段开始时间
endTime DateTime 阶段结束时间
info List 阶段描述信息
duration int 时长(分钟,自动计算)

SleepStageEnum(阶段枚举)

枚举值 描述
light 浅睡/冥想
deep 深睡
rem REM睡眠
awake 清醒
unknown 未知状态

4. 示例App项目

插件提供了完整的示例项目,可直接克隆源码运行体验:

# 克隆仓库
git clone https://github.com/wp993080086/sleep_stage_chart.git

# 进入示例目录
cd sleep_stage_chart/example

# 安装依赖并运行
flutter pub get
flutter run

示例项目包含了睡眠图表、冥想图表的各种定制场景,可直接参考复用。

5. 总结

sleep_stage_chart 是一款功能全面、高度可定制的 Flutter 健康数据可视化插件,凭借其优雅的设计风格、流畅的交互体验和跨平台兼容性,能够快速满足睡眠和冥想数据的可视化需求。无论是快速集成基础图表,还是深度定制符合 App 风格的可视化效果,该插件都能提供简洁高效的解决方案。

适用场景:

  • 睡眠监测类 App:展示深睡、浅睡、REM 睡眠周期分布
  • 冥想类 App:统计每日/每周冥想时长和时段分布
  • 健康管理 App:整合睡眠与冥想数据的综合可视化
  • 智能穿戴设备配套 App:同步设备采集的睡眠数据展示

如果你的项目中需要实现睡眠周期分析或冥想数据展示,不妨试试 sleep_stage_chart,让健康数据可视化开发更高效!

相关链接:


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

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

往期文章

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 获得更好的开发体验。保持组件的单一职责,不要写太复杂的组件。

判断子树点权和是否为 k 的倍数(Python/Java/C++/Go/JS/Rust)

最大化连通块的数目,等价于最大化删除的边数加一。

什么样的边可以删除?

如果 $x$ 和 $y$ 都是 $k$ 的倍数,那么 $x+y$ 也是 $k$ 的倍数。比如 $3$ 和 $6$ 都是 $3$ 的倍数,那么 $3+6=9$ 也是 $3$ 的倍数。

反过来说(逆否命题),如果 $x+y$ 不是 $k$ 的倍数,那么 $x$ 和 $y$ 不全是 $k$ 的倍数。不是 $k$ 的倍数的数,继续拆分,始终存在一个不是 $k$ 的倍数的数。

对应到删边上,删除一条边后,我们把一个连通块分成了两个连通块。如果其中一个连通块的点权和不是 $k$ 的倍数,那么这个连通块无论如何分割,始终存在一个点权和不是 $k$ 的倍数的连通块。所以当且仅当这两个连通块的点权和都是 $k$ 的倍数,这条边才能删除。

删除后,由于分割出的连通块点权和仍然是 $k$ 的倍数,所以可以继续分割,直到无法分割为止。换句话说,只要有能删除的边,就删除。

如何找到可以删除的边?

删除一条边后,我们把一个连通块分成了两个连通块。由于题目保证整棵树的点权和是 $k$ 的倍数,所以只需看其中一个连通块的点权和是否为 $k$ 的倍数。

从任意点出发 DFS 这棵树。计算子树 $x$ 的点权和 $s$,如果 $s$ 是 $k$ 的倍数,那么可以删除 $x$ 到其父节点这条边。注意根节点没有父节点。

连通块的数目等于删除的边数加一。可以把根节点到其父节点这条边(虽然不存在)也算上,这样答案就是删除的边数。

class Solution:
    def maxKDivisibleComponents(self, n: int, edges: List[List[int]], values: List[int], k: int) -> int:
        g = [[] for _ in range(n)]
        for x, y in edges:
            g[x].append(y)
            g[y].append(x)

        # 返回子树 x 的点权和
        def dfs(x: int, fa: int) -> int:
            s = values[x]
            for y in g[x]:
                if y != fa:  # 避免访问父节点
                    # 加上子树 y 的点权和,得到子树 x 的点权和
                    s += dfs(y, x)  
            nonlocal ans
            ans += s % k == 0
            return s

        ans = 0
        dfs(0, -1)
        return ans
class Solution {
    private int ans;

    public int maxKDivisibleComponents(int n, int[][] edges, int[] values, int k) {
        List<Integer>[] g = new ArrayList[n];
        Arrays.setAll(g, _ -> new ArrayList<>());
        for (int[] e : edges) {
            int x = e[0];
            int y = e[1];
            g[x].add(y);
            g[y].add(x);
        }

        dfs(0, -1, g, values, k);
        return ans;
    }

    // 返回子树 x 的点权和
    private long dfs(int x, int fa, List<Integer>[] g, int[] values, int k) {
        long sum = values[x];
        for (int y : g[x]) {
            if (y != fa) { // 避免访问父节点
                // 加上子树 y 的点权和,得到子树 x 的点权和
                sum += dfs(y, x, g, values, k);
            }
        }
        ans += sum % k == 0 ? 1 : 0;
        return sum;
    }
}
class Solution {
public:
    int maxKDivisibleComponents(int n, vector<vector<int>>& edges, vector<int>& values, int k) {
        vector<vector<int>> g(n);
        for (auto& e : edges) {
            int x = e[0], y = e[1];
            g[x].push_back(y);
            g[y].push_back(x);
        }

        int ans = 0;

        // 返回子树 x 的点权和
        auto dfs = [&](this auto&& dfs, int x, int fa) -> long long {
            long long sum = values[x];
            for (int y : g[x]) {
                if (y != fa) { // 避免访问父节点
                    // 加上子树 y 的点权和,得到子树 x 的点权和
                    sum += dfs(y, x);
                }
            }
            ans += sum % k == 0;
            return sum;
        };

        dfs(0, -1);
        return ans;
    }
};
func maxKDivisibleComponents(n int, edges [][]int, values []int, k int) (ans int) {
g := make([][]int, n)
for _, e := range edges {
x, y := e[0], e[1]
g[x] = append(g[x], y)
g[y] = append(g[y], x)
}

// 返回子树 x 的点权和
var dfs func(int, int) int
dfs = func(x, fa int) int {
s := values[x]
for _, y := range g[x] {
if y != fa { // 避免访问父节点
// 加上子树 y 的点权和,得到子树 x 的点权和
s += dfs(y, x)
}
}
if s%k == 0 {
ans++
}
return s
}

dfs(0, -1)
return
}
var maxKDivisibleComponents = function(n, edges, values, k) {
    const g = Array.from({ length: n }, () => []);
    for (const [x, y] of edges) {
        g[x].push(y);
        g[y].push(x);
    }

    let ans = 0;

    // 返回子树 x 的点权和
    function dfs(x, fa) {
        let sum = values[x];
        for (const y of g[x]) {
            if (y !== fa) { // 避免访问父节点
                // 加上子树 y 的点权和,得到子树 x 的点权和
                sum += dfs(y, x);
            }
        }
        ans += sum % k === 0 ? 1 : 0;
        return sum;
    }

    dfs(0, -1);
    return ans;
};
impl Solution {
    pub fn max_k_divisible_components(n: i32, edges: Vec<Vec<i32>>, values: Vec<i32>, k: i32) -> i32 {
        let n = n as usize;
        let mut g = vec![vec![]; n];
        for e in edges {
            let x = e[0] as usize;
            let y = e[1] as usize;
            g[x].push(y);
            g[y].push(x);
        }

        // 返回子树 x 的点权和
        fn dfs(x: usize, fa: usize, g: &[Vec<usize>], values: &[i32], k: i64, ans: &mut i32) -> i64 {
            let mut sum = values[x] as i64;
            for &y in &g[x] {
                if y != fa { // 避免访问父节点
                    // 加上子树 y 的点权和,得到子树 x 的点权和
                    sum += dfs(y, x, g, values, k, ans);
                }
            }
            if sum % k == 0 {
                *ans += 1;
            }
            sum
        }

        let mut ans = 0;
        dfs(0, 0, &g, &values, k as i64, &mut ans);
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$。
  • 空间复杂度:$\mathcal{O}(n)$。

相似题目

2440. 创建价值相同的连通块

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

❌