普通视图

发现新文章,点击刷新页面。
今天 — 2025年4月1日技术

🔥🔥🔥4 月 1 日尤雨溪突然宣布使用 Go 语言重写 Rolldown 和 Oxc!

2025年4月1日 19:11

前言

今天(202541日),尤雨溪发布推特,将用Go重写rolldowOxc!(不太好分辨是不是 Fake News

Rolldown Oxc 将用 Go 重写

前段时间,TypeScript 官方宣布将使用 Go 重写,速度将快 10x

ts 将用 Go 重写

近年来,前端工程化面临前所未有的挑战:项目规模呈指数级增长,构建时间从秒级延长到分钟级,开发者体验急剧下降。

所以,前端工具链正从 JavaScript/TypeScript 逐步迁移到系统级语言(Go/Rust)。

往期精彩推荐

现状 🚀

Go 阵营
  • esbuild:极快的打包速度(Go 的并发设计优势)。
  • typescript(将来):底层支持Go。
  • Vite 的 Rolldown(可能):利用 Go 重写 Rollup 核心。
Rust 阵营
  • SWC:替代 Babel/TerserRust 的并行解析优势。
  • Oxc:高性能的 JavaScript 工具链(代替 ESLint)。
  • Parcel 2:底层使用 Rust 重写。
  • Rome 工具链:从 TypeScript 转向 Rust

为什么 ❓

JavaScript 作为前端的发家语言,虽然语法简单,适合快速开发,生态丰富,但仍弱于原生编译语言。

但其单线程的设计,使其面对打包文件进行大量I/O操作的场景时无法充分发挥多核系统的优势!

且动态语言特性易引入安全漏洞,比如:原型链污染、依赖注入攻击!

而像 Go 的轻量级协程(Goroutine)和 Rust 的无畏并发模型,能更好利用多核 CPU 资源,

同时作为静态语言,编译时捕获大多数错误!

通常 JavaScript 需依赖 TypeScript 弥补静态类型检查,防止出现 Cannot read property 'x' of undefined 的情况!

将来 🔭

可以遇见,底层工具链逐渐会选择高性能的语言代替 JavaScript/TypeScript

而使用 NodeJs 作为中间层,粘合 JavaScript/TypeScript,是势不可挡的大趋势!

总结

前端工具链向系统语言的迁移反映了工程领域对性能的持续追求。GoRust 各有优势,选择时需结合实际需求和技术储备。

这一趋势预计将持续发展,但本体语言 JavaScript 仍将在配置和插件层保持重要地位!

往期精彩推荐

🏖️ TanStack:一套为现代 Web 开发打造的强大、无头且类型安全的库集合 🔥

2025年4月1日 19:05

前言

前几天介绍了 pinia-colada时,有小伙伴在评论区提到了tanstack query,带着好奇心,我翻阅了相关文档,没想到拔出萝卜带出泥,发现了一个宝藏项目:TanStack

TanStack 它是一套为现代 Web 开发打造的强大、无头且类型安全的库集合。

TanStack 的“无头”设计,把“头”(UI)摘掉,只给你“身体”(功能逻辑),让你自己装上喜欢的“头”。

官网:tanstack.com

Github主页:github.com/TanStack

更多精彩内容请关注我的公众号:萌萌哒草头将军

特性

  • 🔒 类型安全:基于 TypeScript 构建,提供强类型检查和自动补全。
  • 🎨 无头设计:完全掌控 UI 标记和样式,无预置组件。
  • 🔥 框架无关:无缝适配 React、Vue、Solid、Svelte、Angular 等框架。
  • ⚡️ 高性能:轻量级,针对复杂工作流优化。
  • ⚙️ 社区驱动:活跃的开源社区,广泛采纳。
  • 📦 集成支持:与 Netlify、Sentry、Convex 等合作,提升开发体验。

主题

它的核心是几个特别好用的库:

🔍 TanStack Query

用于异步状态管理的强大工具,支持从 REST、GraphQL 或其他数据源获取数据。提供自动缓存、窗口焦点重取、轮询和实时查询功能,帮助开发者轻松管理服务器状态。内置开发工具和变异 API,进一步简化调试和数据更新,适合需要高效数据同步的应用场景。

🎨 TanStack Table

灵活且高性能的表格和数据网格解决方案,采用无头设计,开发者可完全自定义样式和结构。支持过滤、排序、分组、分页等功能,即使面对大规模数据集也能保持流畅。适用于数据密集型应用,如分析工具或企业管理系统。

🧭 TanStack Router

专为 React 设计的类型安全路由库,提供客户端和全栈开发的完整支持。具备嵌套路由、预加载、延迟加载和类型安全的 URL 参数管理等特性。与 React 生态无缝集成,适合构建复杂单页应用或全栈项目。

🚗 TanStack Form

无头表单状态管理和验证工具,不受框架限制,开发者可自由定义 UI。提供类型安全的 API,支持复杂验证逻辑和标准 schema 集成。轻量易用,适合需要高效表单处理的场景,如用户注册或数据录入。

🔧 TanStack Start

基于 TanStack Router 的全栈 React 框架,集成了服务器端渲染(SSR)、流式传输和服务器函数。支持 Vite 构建和文件路由,与 TanStack Query 深度结合,实现快速开发和部署。适用于现代全栈应用,潜力巨大,尽管目前仍处于 Beta 阶段。

❓ 它为什么这么贴心

TanStack 的创始人 Tanner Linsley 是个有故事的人。

Tanner Linsley 主页

他最初在 nozzle.io 工作时,就想着怎么解决自己的开发痛点。后来,他把这些想法变成了开源项目,慢慢长成了今天的 TanStack。它不只是工具,更像是一个理念:简单、灵活、开放。

Tanner 自己全资运营 TanStack LLC,没拿外部投资,就是想让它保持纯粹。这份初心,我觉得挺感动的。

更多精彩内容请关注我的公众号:萌萌哒草头将军

如何在 Unity3D 导入 Spine 动画

作者 北桥苏
2025年4月1日 18:51

一、前言

《如何在 Unity3D 项目中导入 Spine 动画》,虽然在网上有很多这种文章,直接将问题交给 DeepSeek 也能得到具体的操作流程,但是照着他们提供的方法还是能遇到几个问题,比如:

  1. AI 回答没有提到 Unity 无法识别.altas,要修改动画文件后缀部分;
  2. 导入到游戏场景中,动画总是被 Canvas 的背景图或元素挡住,层级低;

所以针对这几点问题和操作流程,再带上 Spine 的基本操作,我整理成文章,那么,下面先参照一下我的工具版本开始。

img

二、工具

  • Unity3D 2020.3
  • Spine 3.8.75
  • spine-unity-3.8-2021-11-10
  • 2D 场景游戏

 

三、Spine 操作

因为练习时,多数动画文件是直接下载的别人制作好的 json 导出文件(非工程)。所以如果要在游戏场景前看效果,可以先导进 Spine 工具中查看一下。

json 导出文件目录

fileName.altas // 图集描述文件
fileName.json  // 骨骼动画数据
fileName.png   // 纹理图集

导入文件

  1. 导入数据 > 选择 “JSON 或二进制文件”> 文件选 “json” 格式文件 > 导入

  2. 保存更改 > 浏览 > 自定义名称的 “spine” 格式文件

img

展示纹理

  1. 纹理解包器 > 图集文件选择 “.atlas” 格式文件 > 输出文件夹随便指定即可 > 解开

2.Hierarchy > 图片 > 图片文件 > 路径,找到纹理解包后的输出文件夹

img

其他

通常一个 json 导出文件里可以包含很多个动画,可以看到预览右边的列表,每一个选项就是一个动画,选中可以预览,左边的工程窗的设置可以进入 K 帧窗。

更多的比如骨骼绑定,蒙皮刷权重,K 帧,我后面再出一个单独的文章说明,下面就进入正题到 Unity 的导入了。

img

四、导入 Unity3D

要在 Unity3D 导入前需要先下载 Spine Unity 运行库,可以网上找 Spine 官方下载页;第二个是前面提到的 Unity 不识别.atlas 文件,将动画放到 Unity 项目的资源文件夹下需要将.atlas 改成.txt 后缀的文件。

Spine Unity 运行库安装

在 Unity 中,点击菜单栏 Assets > Import Package > Custom Package,选择下载的.unitypackage文件,导入全部内容。

最后在工具栏的 GameObject 下,查看是否有 Spine 选项,就可以验证安装成功与否。

Spine 数据资源生成

前面提到的 Spine 动画的三个文件,再放入游戏资产文件夹中修改了图片描述文件格式后,打开项目后,Unity 会自动生成一个.meta 和两个.asset 文件。

_Atlas.asset             与之前的atlas对应的图集描述文件
_Material.mat            与之前的png对应的素材纹理
_SkeletonData.asset      与之前的json对应的骨骼数据文件

创建动画对象

因为游戏是 2D 的,我主体场景用的是 Canvas,开头也提到过的导入后总是显示在 Canvas 背景图的下层,所以第一步就从设置画面开始。

  1. 选中 Canvas 后,Render Mode 选择 “Screen Space-Camera”,Hierachy 中的 Main Camera 拖入 Render Camera 中。

img

  1. 在场景中创建空对象(如右键 Hierarchy > Create Empty),命名为 SpineCharacter

  2. 添加组件:点击 Add Component,搜索并添加 Skeleton Animation

  3. 配置组件:

  • Skeleton Data Asset:拖入前面 “Spine 数据资源” 生成的_SkeletonData.asset文件。
  • Animation Name:输入默认播放的动画名称(如idle)。
  • Loop:勾选以循环播放。
  • Scale:调整 Rect Transform 的 Scale,大小自行尝试。

5.Shader 设置 UI/Default,默认的 Spine Skeleton 素材拼接得有点锯齿,详细的可以自行选择尝试。

img

脚本控制动画

创建 C# 脚本(如SpineController.cs)并附加到角色对象

using Spine.Unity;
public class SpineController : MonoBehaviour {
    public SkeletonAnimation skeletonAnim;
    public string runAnimation = "run";
    
    void Start() {
        skeletonAnim.AnimationState.SetAnimation(0, runAnimation, true);
    }
    
    // 切换动画
    public void PlayAnimation(string animName) {
        skeletonAnim.AnimationState.SetAnimation(0, animName, true);
    }
}

写在后面

为什么我做小游戏从 CocosCreator 切到了 Unity3D,原因是这次尝试的是抖音直播小玩法,也就是弹幕小游戏。

前期为了跑通流程,所以我要避开所有要用到服务端的产品开发,从而采用指令直推方式,但官网只有 Unity SDK 的案例,最后有了这篇文章,当然后续还有更多。

当然 CocosCreator 制作普通单机小游戏我也会继续,不过当前以 2D 为主,毕竟现在用 AI 抽卡来生成游戏资产太方便了,等图生 3D 模型较完善的时候,以后再用 AI 制作 3D 小游戏。

记一次错误使用 useEffect 导致电脑差点“报废”

作者 Alang
2025年4月1日 18:44

背景

起因我在开发 A 项目,过程中写了一点代码(未仔细检查),然后就挂着 A 项目去开发 B 项目了。感觉电脑差点报废,原因竟然是....

不对劲

结果这几天我一直发现电脑有点卡,明显感觉不对劲,回忆下这几天不对劲的点:

  1. 周末挂机2天,电脑 CPU 温度变成 100°,我还以为是没关机过,刻意锁屏休眠了一下,不过温度降到 80°还是很高。
  2. 偶尔切换窗口卡顿,编码卡顿
  3. 网络卡顿

就这样持续了 3 天,我都没去管!以为是电脑长时间没关机的原因,周末关机试一下!

按理说不应该,mac 长时间不关键不影响呀,何况我 mac mini 都没电池,怕啥!

排查问题

一、查看电脑进程,发现一个 Google Chorome Helper 占用较高!

image.png

二、打开 Chorome > 更多工具 > 任务管理器

image.png

cpu 飙升到 155%!

三、发现了目标标签,打开标签,发现有个报错信息一直在轮询报错

image.png

四、check A 项目代码!根据报错信息搜索 useEffect 找到了如下罪魁祸首!!!

const Demo = ({ data }) => {
    // 选中的值
    const [selectedKeys, setSelectedKeys] = useState([])
    
    // 选项列表
    const options = data.map(v => ({
      label: v,
      value: v
    }))
    
    // 监听,默认全部选中
    useEffect(() => {
      setSelectedKeys(options.map(v => v.value))
    }, [options])
}

这代码相信大家一看就明白了吧!useEffect 会 setSelectedKeys,然后组件重新渲染, options 就会改变,然后又会触发 useEffect!

解决方法,监听 options 改成props 的 data 就好了。

// 监听,默认全部选中
useEffect(() => {
  setSelectedKeys(options.map(v => v.value))
}, [data])

事后总结

事后想想太可怕了,CPU 一直保持在 100°,然后磁盘读取、操作都变得卡顿。原因竟然是自己的粗心,加上当时 B 项目有点紧急,就没仔细测试和看到标签的报错信息,导致这个标签一直挂着,然后 CPU 就炸了!流汗!发出来供大家娱乐一下(嘲笑)。

大家引以为戒呀!心疼我的电脑 3s!

CSR mode下基于react+i18next实践国际化多语言解决方案

2025年4月1日 18:35

代码链接:gitee.com/yu_jianchen…

系统目录

|- node_modules
|- src
|-- utils
|--- event-bus.ts
|-- config
|--- i18n-hmr.ts
|-- @type
|--- resources.ts
|--- i18next.d.ts
|--- constants.ts
|--- dayjs.d.ts
|-- providers
|--- i18n-provider.tsx
|-- locales
|--- common
|---- en.json
|---- zh_CN.json
|--- language
|---- en.json
|---- zh_CN.json
|-- App.tsx
|-- i18n.ts
|-.gitinore
|- eslint.config.js
|- index.html
|- package.json
|- pnpm-lock.yaml
|- README.md
|- tsconfig.json
|- tsconfig.node.json
|- vite.config.js

版本汇总

  • node: v20.12.0
  • pnpm: v8.14.3

基础配置

  1. 安装相关插件
pnpm install react-i18next i18next
  1. 在src目录下创建i18n.ts文件,配置i18n相关语法
import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'

i18next.use(initReactI18next).init({
    lng: 'zh',
    fallbackLng: 'en',
    resources: {
        en: {
            translation: en,
        },
        zh: {
            translation: zhCN,
        }
    }
})

export const { t } = i18next;

文件路径简化

在vite.config.js文件下做如下配置,简化引入文件时的路径

import { defineConfig } from "vite";
export default definConfig({
    resolve: {
        alias: {
            "@": "/src"
        }
    }
})

解决typescript类型问题

建立typescript类型检查及智能提示体系

  1. 在@type目录下创建resources文件,全量引入资源文件并规范类型操作
import common_en from "@/locales/modules/common/en.json";
import common_zhCN from "@/locales/modules/common/zh_CN.json";
import lang_zhCN from "@/locales/modules/languages/zh_CN.json";
import lang_en from "@/locales/modules/languages/en.json";

const resources = {
  en: {
    common: common_en,
    lang: lang_en
  },
  'zh_CN': {
    common: common_zhCN,
    lang: lang_zhCN
  },
} as const;

export default resources;

export type Resources = typeof resources;
  1. 在@type目录下创建i18next.d.ts文件,规范配置类型
import 'i18next'
import { Resources } from './resources'

declare module 'i18next' {
    interface CustomTypeOptions {
        defaultNS: 'translation';
        resources: Resources;
    }
}
  1. 修改i18n.ts文件
import resources from "./@types/resources";
import i18next from "i18next";
import { initReactI18next } from "react-i18next";

export const defaultNs = "common" as const;
export const fallbackLanguage = "en" as const;
export const language = "en" as const;
export const ns = ["common", "language"] as const;

export const initI18n = () => {
  return i18next.use(initReactI18next).init({
    lng: language,
    fallbackLng: fallbackLanguage,
    defaultNS: defaultNs,
    ns,
    resources,
  });
}

按需加载语言

随着项目增大,全量加载语言资源包可能会影响打包体积,从而降低性能 使用按需加载来解决这个问题,但是i18next并没有按需加载的事件,所以自己写个逻辑

  1. 修改resources文件,默认加载英语资源
import common_en from "@/locales/modules/common/en.json";
import lang_en from "@/locales/modules/languages/en.json";

const resources = {
  en: {
    common: common_en,
    lang: lang_en
  }
} as const;

export default resources;

export type Resources = typeof resources;
  1. 在providers目录下i18n-provider.tsx文件中写按需相关逻辑 思路如下:
  • 导出I18nProvider组件,组件监控i18n字段的值,字段变更触发langChangeHandler事件,开始走按需逻辑代码
  • 创建Set集合,存储非重复成员
  • 动态加载语言资源
  • 调用i18next的addResourceBundle()加载资源
  • 调用i18next的changeLanuage()切换语言
  • 页面重排

代码如下:

import i18next from "i18next";
import { I18nextProvider } from "react-i18next";
import resources from "@/@types/resources";
import { initI18n } from "@/i18n";
import { atom, useAtom } from "jotai";
import {
  useEffect,
  useLayoutEffect,
  type FC,
  type PropsWithChildren,
} from "react";

// 初始化i18n
initI18n();

export const i18nAtom = atom(i18next);

const loadingLangLock = new Set<string>();

const langChangeHandler = async (lang: string) => {
  // 如切换的语言重复,直接return
  if (loadingLangLock.has(lang)) return;

  loadingLangLock.add(lang);

    // 动态加载指定路径下的语言资源包
    const nsGlobbyMap = import.meta.glob("/src/locales/*/*.json", {
      eager: true,
    });

    // 获取所有所需文件名
    const namespaces = Object.keys(resources.en);

    // 同步加载
    const res = await Promise.allSettled(
      namespaces.map(async (ns) => {
        // 指定路径
        const filePath = `/src/locales/${ns}/${lang}.json`;
        // 加载资源
        const module = nsGlobbyMap[filePath] as {
          default: Record<string, any>;
        };

        if (!module) return;

        // 执行i18next多语言加载事件
        i18next.addResourceBundle(lang, ns, module.default, true, true);
      })
    );

    // 异常捕获
    for (const r of res) {
      if (r.status === "rejected") {
        console.log(`error: ${lang}`);
        loadingLangLock.delete(lang);
      }
      return;
    }

  // i18next重新loading
  await i18next.reloadResources();
  // 切换所需语言
  await i18next.changeLanguage(lang);
  // 当前lang加载完毕后delete
  loadingLangLock.delete(lang);
};

export const I18nProvider: FC<PropsWithChildren> = ({ children }) => {
  const [currentI18NInstance, update] = useAtom(i18nAtom);

  // 监控i18n字段变更
  useLayoutEffect(() => {
    const [currentI18NInstance, update] = useAtom(i18nAtom)
    // 字段变更触发langChangeHandler
    i18next.on("languageChanged", langChangeHandler);

    // 组件销毁解除监听,防止内存泄漏
    return () => {
      i18next.off("languageChanged", langChangeHandler);
    };
  }, [currentI18NInstance]);

  return (
    <I18nextProvider i18n={currentI18NInstance}>{children}</I18nextProvider>
  );
};

生产环境中合并namespace

精简请求,将多个不同业务的语言包汇总成一个语言包,这样在生产环境就只需要请求一次多语言包 思路如下

  • 确定路径
  • 遍历所有语言包
  • 汇总所有字段
  • 生成合并后的语言文件
  • 指定vite生命周期下提交相关文件操作
  • 删除原始的 JSON 文件
  1. 撰写相关逻辑
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs";
import type { Plugin } from "vite";
import type { OutputBundle, OutputAsset } from "rollup";
import { set } from "es-toolkit/compat"

export default function localesPlugin(): Plugin {
  return {
    name: "locales-merge",
    // enforce: pre -- 在其他插件之前执行  默认值--在核心插件执行之后  post -- 在其他插件之后执行
    enforce: "post",
    generateBundle(options: any, bundle: OutputBundle) {
      const __filename = fileURLToPath(import.meta.url);
      const __dirname = path.dirname(__filename);

      const localesDir = path.resolve(__dirname, "../locales/modules");
      const namespace = fs.readdirSync(localesDir);

      const languageResources: Record<string, Record<string, any>> = {};

      // 收集所有语言资源
      namespace.forEach((namespace: string) => {
        const namespacePath = path.join(localesDir, namespace);
        if (!fs.statSync(namespacePath).isDirectory()) return;

        const files = fs
          .readdirSync(namespacePath)
          .filter((file: string) => file.endsWith(".json"));

        files.forEach((file: string) => {
          const lang = path.basename(file, ".json");
          const filePath = path.join(namespacePath, file);
          const content = JSON.parse(fs.readFileSync(filePath, "utf-8"));

          if (!languageResources[lang]) {
            languageResources[lang] = {};
          }

          const obj = {};

          const keys = Object.keys(content as object);

          for(const accessorKey of keys) {
            set(obj, accessorKey, (content as any)[accessorKey]);
          }

          languageResources[lang][namespace] = obj;
        });

      });

      // 生成合并后的语言文件
      Object.entries(languageResources).forEach(([lang, resources]) => {
        const fileName = `locales/${lang}.js`;
        const content = `export default ${JSON.stringify(resources, null, 2)};`;
        this.emitFile({
          type: "asset",
          fileName,
          source: content,
        });
      });

      // 删除原始的 JSON 文件
      for (const fileName of Object.keys(bundle)) {
        const file = bundle[fileName] as OutputAsset;
        // 检查是否是 JSON 文件并且在 locales 目录下
        if (
          file.type === "asset" &&
          fileName.includes("/locales/") &&
          fileName.endsWith(".json")
        ) {
          delete bundle[fileName];
        }
      }
    },
  };
}
  1. 在vite.config.js下导入
import { defineConfig } from "vite";
export default definConfig({
    base: "./", // 新增这行配置
    plugins: [react(), localesPlugin()],
    resolve: {
        alias: {
            "@": "/src"
        }
    },
    build: {
    rollupOptions: {
      input: {
        main: "./index.html",
      },
      output: {
         // 文件指定导出
        assetFileNames: (assetInfo) => {
          if (assetInfo.name.endsWith(".json")) {
            return "[dir]/[name][extname]";
          }
          if (
            assetInfo.name.includes("locales") &&
            assetInfo.name.endsWith(".js")
          ) {
            return "locales/[name][extname]";
          }
          if (assetInfo.name.endsWith(".css")) {
            // 新增 CSS 文件路径处理
            return "assets/[name][extname]";
          }
          return "assets/[name]-[hash][extname]";
        },
      },
    },
  },
})

3. 修改i18Provider.tsx文件,分情况导入文件

import i18next from "i18next";
import { atom, useAtom } from "jotai";
import {
  useEffect,
  useLayoutEffect,
  type FC,
  type PropsWithChildren,
} from "react";
import { I18nextProvider } from "react-i18next";
import resources from "@/@types/resources";
import { initI18n } from "@/i18n";
import { isEmptyObject } from "@/utils/index";

// 初始化i18n
initI18n();

export const i18nAtom = atom(i18next);

const loadingLangLock = new Set<string>();

const langChangeHandler = async (lang: string) => {
  if (loadingLangLock.has(lang)) return;

  //   const loaded = i18next.getResourceBundle(lang, defaultNs);
  //   if (loaded) return;

  loadingLangLock.add(lang);

  if (import.meta.env.DEV) {
    const nsGlobbyMap = import.meta.glob("/src/locales/modules/*/*.json", {
      eager: true,
    });

    const namespaces = Object.keys(resources.en);

    const res = await Promise.allSettled(
      namespaces.map(async (ns) => {
        const filePath = `/src/locales/modules/${ns}/${lang}.json`;
        const module = nsGlobbyMap[filePath] as {
          default: Record<string, any>;
        };

        if (!module) return;

        i18next.addResourceBundle(lang, ns, module.default, true, true);
      })
    );

    for (const r of res) {
      if (r.status === "rejected") {
        console.log(`error: ${lang}`);
        loadingLangLock.delete(lang);
      }
      return;
    }
  } else {
    const res = await import(/* @vite-ignore */ `../locales/${lang}.js`) // [!code ++]
      .then((res) => res?.default || res)
      .catch(() => {
        loadingLangLock.delete(lang);
        return {};
      }); // 使用import的方式加载

    if (isEmptyObject(res)) {
      return;
    }

    for (const namespace in res) {
      i18next.addResourceBundle(lang, namespace, res[namespace], true, true);
    }
  }

  await i18next.reloadResources();
  await i18next.changeLanguage(lang);
  loadingLangLock.delete(lang);
};

export const I18nProvider: FC<PropsWithChildren> = ({ children }) => {
  const [currentI18NInstance, update] = useAtom(i18nAtom);

  useEffect(() => {
    if (import.meta.env.DEV) {
      EventBus.subscribe("I18N_UPDATE", (lang) => {
        console.log(I18N_COMPLETENESS_MAP[lang], lang);
        const nextI18n = i18next.cloneInstance({
          lng: lang,
        });
        update(nextI18n);
      });
    }
  }, [update]);

  useLayoutEffect(() => {
    const i18next = currentI18NInstance;
    i18next.on("languageChanged", langChangeHandler);

    return () => {
      i18next.off("languageChanged", langChangeHandler);
    };
  }, [currentI18NInstance]);

  return (
    <I18nextProvider i18n={currentI18NInstance}>{children}</I18nextProvider>
  );
};
  1. 效果如图: image.png

image.png

动态加载日期库的i18n

兼顾日期库的多地区变化

  1. 新建constants配置文件,维护dayjs国际化配置的 import 表
type LocaleLoader = () => Promise<any>;

export const dayjsLocaleImportMap: Record<string, [string, LocaleLoader]> = {
  en: ["en", () => import("dayjs/locale/en")],
  ["zh_CN"]: ["zh-cn", () => import("dayjs/locale/zh-cn")],
  ["ja"]: ["ja", () => import("dayjs/locale/ja")],
  ["fr"]: ["fr", () => import("dayjs/locale/fr")],
  ["pt"]: ["pt", () => import("dayjs/locale/pt")],
  ["zh_TW"]: ["zh-tw", () => import("dayjs/locale/zh-tw")],
};
  1. 新建day.d.ts文件,declare规范数据类型
declare module 'dayjs/locale/*' {
  const locale: any;
  export default locale;
}
  1. 修改i18nProvider文件,适配dayjs国际化配置
const loadLocale = async (lang: string) => {
  if (lang in dayjsLocaleImportMap) {
    const [localeName, importFn] = dayjsLocaleImportMap[lang];
    await importFn();
    dayjs.locale(localeName);
  }
};

const langChangeHandler = async (lang: string) => {
  loadLocale(lang).then(() => {
    console.log(dayjs().format("YYYY年MM月DD日"));
  });
  
  省略一下代码...
}

DX优化: HMR支持

在开发环境中,多语言资源修改会导致页面整个重排,现在想让系统只热更新指定部分 思路如下

  1. 新建i18n-hmr捕获热更新操作,如热更新文件满足需求,触发server.ws.send事件
import { readFileSync } from "node:fs";

import type { Plugin } from "vite";

export function customI18nHmrPlugin(): Plugin {
 return {
   name: "custom-i18n-hmr",
   handleHotUpdate({ file, server }) {
     if (file.endsWith(".json") && file.includes("locales")) {
       server.ws.send({
         type: "custom",
         event: "i18n-update",
         data: {
           file,
           content: readFileSync(file, "utf-8"),
         },
       });
       // 返回一个空数组,告诉 Vite 不需要重新加载模块
       return [];
     }
   },
 };
}
  1. 在i18n.ts中进行监控自定义派发时间,加载指定语言资源并reload
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import resources from "./@types/resources";
import { atom } from "jotai";
import { jotaiStore } from "@/lib/jotai";
import { EventBus } from "@/utils/event-bus";

export const defaultNs = "common" as const;
export const fallbackLanguage = "en" as const;
export const language = "en" as const;
export const ns = ["common", "settings"] as const;

export const i18nAtom = atom(i18next);

export const initI18n = () => {
  const i18next = jotaiStore.get(i18nAtom);
  return i18next.use(initReactI18next).init({
    lng: language,
    fallbackLng: fallbackLanguage,
    defaultNS: defaultNs,
    ns,
    resources,
  });
};

if (import.meta.hot) {
  import.meta.hot.on(
    "i18n-update",
    async ({ file, content }: { file: string; content: string }) => {
      const resources = JSON.parse(content);
      const i18next = jotaiStore.get(i18nAtom);

      const nsName = file.match(/modules\/([^/\\]+)/)?.[1];
      
      if (!nsName) {
        return;
      }
      const lang = file.split("/").pop()?.replace(".json", "");
      if (!lang) {
        return;
      }

      i18next.addResourceBundle(lang, nsName, resources, true, true);

      await i18next.reloadResources(lang, nsName);
      // 加载完成,通知组件重新渲染
      import.meta.env.DEV && EventBus.dispatch("I18N_UPDATE", lang); 
    }
  );
}

declare module "@/utils/event-bus" {
  interface CustomEvent {
    I18N_UPDATE: string;
  }
}

export const { t } = i18next;

3. 新建Event-bus文件,自定义Event-bus订阅发布事件

export interface CustomEvent {}
export interface EventBusMap extends CustomEvent {}

class EventBusEvent extends Event {
  static type = "EventBusEvent";
  constructor(public _type: string, public data: any) {
    super(EventBusEvent.type);
  }
}

type AnyObject = Record<string, any>;
class EventBusStatic<E extends AnyObject> {
  dispatch<T extends keyof E>(event: T, data: E[T]): void;
  dispatch<T extends keyof E>(event: T): void;
  dispatch<T extends keyof E>(event: T, data?: E[T]) {
    window.dispatchEvent(new EventBusEvent(event as string, data));
  }

  subscribe<T extends keyof E>(event: T, callback: (data: E[T]) => void) {
    const handler = (e: any) => {
      if (e instanceof EventBusEvent && e._type === event) {
        callback(e.data);
      }
    };
    window.addEventListener(EventBusEvent.type, handler);

    return this.unsubscribe.bind(this, event as string, handler);
  }

  unsubscribe(_event: string, handler: (e: any) => void) {
    window.removeEventListener(EventBusEvent.type, handler);
  }
}

export const EventBus = new EventBusStatic<EventBusMap>();
export const createEventBus = <E extends AnyObject>() =>
  new EventBusStatic<E>();

4. 通知i18nProvider文件刷新组件

export const I18nProvider: FC<PropsWithChildren> = ({ children }) => {
  const [currentI18NInstance, update] = useAtom(i18nAtom);

  useEffect(() => {
    if (import.meta.env.DEV) {
      EventBus.subscribe("I18N_UPDATE", (lang) => {
        console.log(I18N_COMPLETENESS_MAP[lang], lang);
        const nextI18n = i18next.cloneInstance({
          lng: lang,
        });
        update(nextI18n);
      });
    }
  }, [update]);

  useLayoutEffect(() => {
    const i18next = currentI18NInstance;
    i18next.on("languageChanged", langChangeHandler);

    return () => {
      i18next.off("languageChanged", langChangeHandler);
    };
  }, [currentI18NInstance]);

  return (
    <I18nextProvider i18n={currentI18NInstance}>{children}</I18nextProvider>
  );
};

计算语言完成翻译度

自动化统计各语言翻译完成度,避免疏漏

  1. 新建i18n-completeness文件,自动化统计指定语言完成度
import fs from "node:fs";
import path from "node:path";

type languageCompletion = Record<string, number>;

function getLanguageFiles(dir: string): string[] {
  return fs.readdirSync(dir).filter((file) => file.endsWith(".json"));
}

function getNamespaces(localesDir: string): string[] {
  return fs
    .readdirSync(localesDir)
    .filter((file) => fs.statSync(path.join(localesDir, file)).isDirectory());
}

function countKeys(obj: any): number {
  let count = 0;
  console.log("开始计算对象的键数量:", obj);
  for (const key in obj) {
    if (typeof obj[key] === "object") {
      count += countKeys(obj[key]);
    } else {
      count++;
    }
  }
  return count;
}

function calculateCompleteness(localesDir: string): languageCompletion {
  console.log("开始计算完整度,目录:", localesDir);
  const namespaces = getNamespaces(localesDir);
  console.log("找到的命名空间:", namespaces);
  const languages = new Set<string>();
  const keyCount: Record<string, number> = {};

  namespaces.forEach((namespace) => {
    const namespaceDir = path.join(localesDir, namespace);
    console.log("处理命名空间目录:", namespaceDir);

    const files = getLanguageFiles(namespaceDir);
    console.log(`命名空间 ${namespace} 中的语言文件:`, files);

    files.forEach((file: string) => {
      const lang = path.basename(file, ".json");
      console.log(`处理语言文件: ${file}, 提取语言代码: ${lang}`);
      languages.add(lang); // [!code --]

      try {
        const filePath = path.join(namespaceDir, file);
        console.log("读取文件:", filePath);
        const content = JSON.parse(fs.readFileSync(filePath, "utf-8"));
        const keys = countKeys(content);
        console.log(`文件 ${file} 中的键数量:`, keys);
        keyCount[lang] = (keyCount[lang] || 0) + keys;
      } catch (error) {
        console.error(`处理文件 ${file} 时出错:`, error);
      }
    });
  });

  console.log("所有语言的键数量:", keyCount);
  console.log("检测到的语言:", Array.from(languages));

  const enCount = keyCount["en"] || 0;
  console.log("英语键数量 (基准):", enCount);
  const completeness: languageCompletion = {};

  languages.forEach((lang) => {
    if (lang !== "en") {
      const percent = Math.round((keyCount[lang] / enCount) * 100);
      completeness[lang] = percent;
      console.log(`语言 ${lang} 的完整度: ${percent}%`);
    }
  });

  console.log("最终完整度结果:", completeness);
  return completeness;
}

const i18n = calculateCompleteness(
  path.resolve(__dirname, "../locales/modules")
);
export default i18n;
  1. 修改i18nProvider文件,console打印指定语言完成度
const langChangeHandler = async (lang: string) => {
    console.log(I18N_COMPLETENESS_MAP[lang], lang);
}
  1. 效果如图

image.png

扁平key的处理

有些多语言是直接文件名.key这样的形式存放的,不是通过创建目录-文件-key的形式,比如

common_link: 1111

要对这种格式的多语言进行处理,处理成集合嵌套的方式,如下

common: {
    link: 111
}
  1. 修改vite.render.config.ts
import { set } from "es-toolkit/compat"
...省略以上代码
generateBundle(options: any, bundle: OutputBundle) {
    ...省略以上代码
    files.forEach((file: string) => {
          const lang = path.basename(file, ".json");
          const filePath = path.join(namespacePath, file);
          const content = JSON.parse(fs.readFileSync(filePath, "utf-8"));

          if (!languageResources[lang]) {
            languageResources[lang] = {};
          }

          const obj = {}; // [!code ++]

          const keys = Object.keys(content as object); // [!code ++]

          for(const accessorKey of keys) { // [!code ++]
            set(obj, accessorKey, (content as any)[accessorKey]); // [!code ++]
          } // [!code ++]

          languageResources[lang][namespace] = obj; // [!code ++]
        });
    ...省略以下代码
}

该方案实现节点如下

  1. 按需引入
  2. 动态加载
  3. Event-bus自定义订阅发布事件
  4. 合并namespace
  5. 语言完成度自动化计算
  6. HMR支持
  7. 多种key适配

前端学AI:LangGraph学习-基础概念

作者 牛奶
2025年4月1日 18:27

前端学AI:LangGraph学习-基础概念

本文主要介绍下LangGraph 的一些基础概念,包括定义、应用场景、核心概念和主要功能,让读者了解什么是LangGraph,以及它能做什么,在哪些场景用,使用哪些主要功能等。

供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

最近在学习 LangGraph,顺便分享下。

什么是LangGraph

LangGraphLangChain 的扩展库,专门用于构建和管理复杂的状态机工作流程。提供声明式的工作流程定义方式,以及支持循环计算状态管理,使得复杂业务逻辑的实现变得更加简洁和高效。

在 LangChain 的基础上扩展了功能,LangGraph 增加了对异步操作错误处理并发任务的支持。它的状态化特性使得系统能够记住之前的交互用户偏好,从而提供更个性化的响应。

同时,其支持循环计算的能力突破了传统工作流的限制,使开发者能够更自然地模拟现实世界中的决策过程。

LangGraph应用场景

LangGraph 凭借其支持循环计算状态管理的特性,在多种复杂场景中表现出色。

交互式代理(Interactive Agents)

它的状态化特性使其非常适合构建能够进行多轮对话的智能代理。可以通过状态图(State Graph)跟踪对话历史,并根据用户输入动态调整后续的响应逻辑。循环计算功能还允许代理在需要时反复询问澄清问题,确保对话自然流畅。这种能力特别适用于需要上下文感知的场景。

例如,聊天机器人可以利用 LangGraph 记住用户的请求(如“我的订单状态”),并在多轮对话中逐步收集必要信息(订单号、用户信息等),最终提供准确的答复。如果用户的问题不完整,机器人可以通过循环逻辑返回到澄清步骤,直到满足条件。

复杂决策系统(Complex Decision-Making Systems)

LangGraph 在复杂决策系统中表现出色,它能够通过条件边(Conditional Edges)和状态管理处理多变量标准,并根据实时数据更新决策路径

例如,在自动驾驶场景中,可以用来构建决策系统,处理诸如“是否变道”“是否减速”等复杂判断。系统通过状态图跟踪车辆状态(如速度、周围环境),并根据传感器数据动态选择行动。如果条件未满足(如前方有障碍物),它可以循环回到评估步骤,直到安全执行决策。

迭代处理模型(Iterative Processing Models)

它的循环计算功能非常适合需要通过多次迭代改进结果的任务。可以在每次循环中根据反馈或评估标准调整输出,直到满足预设条件。

例如,内容生成系统可以用来生成文章或创意文本。系统首先生成初稿,然后通过循环逻辑根据用户反馈(如“更简洁一点”)或内部评估(如语法检查)进行优化。每次迭代都会更新状态,最终输出符合要求的内容。

LangGraph核心概念

概念 定义 主要功能 详细解释
节点(Nodes) LangGraph 中的计算单元,通常是同步或异步函数,负责执行工作流中的特定逻辑 读取和更新状态通道,执行任务如调用 LLM、处理输入或与外部工具交互。 节点通过与状态通道交互来工作,通道是键值对,存储当前状态。节点可以覆盖或追加通道值,具体取决于归约函数(reducer)。支持自定义逻辑和工具集成,适合多代理系统。
边(Edges) 定义节点之间连接的路径,控制工作流的执行流向 控制流程,可以是静态的(固定下一个节点)或条件性的(动态决策)。 条件边通过函数评估状态,决定下一步节点或终止图。支持分支逻辑和人类干预。
状态图(State Graph) 由节点和边组成的有向图,管理复杂、状态化的工作流 支持循环计算状态管理,通过通道归约函数维护状态。 状态通过通道管理,每个通道有默认值和归约函数。支持持久化机制(如按线程存储),允许长时间运行的工作流和中断恢复。调试工具如 LangGraph Studio 增强可视化。

LangGraph主要功能

循环计算(Cyclic Computations)

循环计算是 LangGraph 的一大亮点,能让任务“转圈”跑,不像传统的工作流程(比如有向无环图,简称 DAG)那样只能直线走到底。

传统的 DAG 就像一条单行道,走到头就停了,而 LangGraph 可以让任务回头再来一遍。

这对模拟“像人一样的智能”(类代理行为)特别有用,比如一个智能助手在找答案时,如果第一次没找对,可以再试一次,调整方法,直到成功。

状态化执行(Stateful Execution)

状态化执行就是 LangGraph 能“记住东西”。它使用 MemorySaver 的工具,把任务的状态(比如对话记录或用户选择)保存下来,像给每个任务开个小笔记本,随时翻看和更新。

这让程序变得更聪明,能根据之前的记录调整下一步,比如一个聊天机器人记住你说过的话,下次回答更贴心。

条件逻辑(Conditional Logic)

条件逻辑是 LangGraph 的“决策大脑”,通过条件边,根据当前情况决定下一步怎么走。

比如,一个聊天机器人发现你没回答完整,它就回头再问你,而不是傻乎乎地继续往下走。

这种灵活的决策方式特别适合需要随机应变的场景,比如自动驾驶根据路况决定刹车还是转弯。

推荐资料

专栏系列

Three.js高效几何体创建指南:BufferGeometry深度解析

作者 Mintopia
2025年4月1日 18:08

1. 为什么选择BufferGeometry?

在Three.js开发中,几何体创建是3D建模的基础。相比传统Geometry,BufferGeometry具有显著优势:

  • 内存效率‌:采用TypedArray存储顶点数据,内存占用减少40%
  • 渲染性能‌:直接对接WebGL缓冲区,减少CPU-GPU数据传输
  • 灵活性‌:支持动态更新顶点数据
  • 扩展性‌:可处理百万级顶点的复杂模型

2. 基础创建流程

2.1 创建空几何体

const geometry = new THREE.BufferGeometry();

2.2 定义顶点数据

// 创建包含12个顶点的立方体(每个面2个三角形)
const vertices = new Float32Array([
  // 前表面
  -1, -1,  1,  // 0
   1, -1,  1,  // 1
   1,  1,  1,  // 2
  -1,  1,  1,  // 3
  
  // 后表面
  -1, -1, -1,  // 4
   1, -1, -1,  // 5
  // ...(完整顶点数据)
]);

// 创建并设置顶点属性
geometry.setAttribute(
  'position', 
  new THREE.BufferAttribute(vertices, 3)
);

2.3 定义索引数据(可选优化)

const indices = new Uint16Array([
  // 前表面
  0, 1, 2,  2, 3, 0,
  
  // 顶部表面
  2, 3, 7,  7, 6, 2,
  // ...(完整索引数据)
]);

geometry.setIndex(new THREE.BufferAttribute(indices, 1));

3. 高级属性配置

3.1 添加法线向量

const normals = new Float32Array(vertices.length);
for (let i = 0; i < vertices.length; i += 9) {
  // 计算三角形法线
  const vA = new THREE.Vector3(...vertices.slice(i, i+3));
  const vB = new THREE.Vector3(...vertices.slice(i+3, i+6));
  const vC = new THREE.Vector3(...vertices.slice(i+6, i+9));
  
  const cb = new THREE.Vector3().subVectors(vC, vB);
  const ab = new THREE.Vector3().subVectors(vA, vB);
  const normal = new THREE.Vector3()
    .crossVectors(cb, ab)
    .normalize();

  // 为每个顶点设置法线
  normals.set([...normal.toArray()], i);
  normals.set([...normal.toArray()], i+3);
  normals.set([...normal.toArray()], i+6);
}

geometry.setAttribute(
  'normal',
  new THREE.BufferAttribute(normals, 3)
);

3.2 添加UV坐标

const uvs = new Float32Array([
  // 前表面UV
  0, 0, 
  1, 0,
  1, 1,
  0, 1,
  
  // 其他面UV坐标...
]);

geometry.setAttribute(
  'uv',
  new THREE.BufferAttribute(uvs, 2)
);

4. 性能优化技巧

4.1 内存复用策略

// 创建可复用数组
const vertexPool = new Float32Array(300000); // 预分配内存

function updateGeometry(geometry) {
  const positions = geometry.attributes.position;
  
  // 直接修改已存在的BufferAttribute
  for (let i = 0; i < positions.count; i++) {
    positions.array[i * 3] += Math.random() * 0.1; // X坐标
    positions.array[i * 3 + 1] *= 0.95; // Y坐标
  }
  
  positions.needsUpdate = true;
}

4.2 几何体合并

const geometries = [];
const material = new THREE.MeshStandardMaterial();

// 生成多个几何体
for (let i = 0; i < 100; i++) {
  const geom = new THREE.BufferGeometry();
  // ...配置几何体
  geometries.push(geom);
}

// 合并几何体
const mergedGeometry = THREE.BufferGeometryUtils.mergeBufferGeometries(
  geometries
);

const mesh = new THREE.Mesh(mergedGeometry, material);
scene.add(mesh);

5. 动态几何体示例:波浪平面

// 初始化平面
const WIDTH_SEGMENTS = 128;
const SIZE = 20;

const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(
  (WIDTH_SEGMENTS + 1) ** 2 * 3
);
const uvs = new Float32Array(
  (WIDTH_SEGMENTS + 1) ** 2 * 2
);

// 生成初始顶点
let vertexIndex = 0;
for (let y = 0; y <= WIDTH_SEGMENTS; y++) {
  for (let x = 0; x <= WIDTH_SEGMENTS; x++) {
    positions[vertexIndex * 3] = 
      (x / WIDTH_SEGMENTS) * SIZE - SIZE/2;
    positions[vertexIndex * 3 + 1] = 0;
    positions[vertexIndex * 3 + 2] = 
      (y / WIDTH_SEGMENTS) * SIZE - SIZE/2;
    
    uvs[vertexIndex * 2] = x / WIDTH_SEGMENTS;
    uvs[vertexIndex * 2 + 1] = y / WIDTH_SEGMENTS;
    
    vertexIndex++;
  }
}

// 设置几何体属性
geometry.setAttribute(
  'position',
  new THREE.BufferAttribute(positions, 3)
);
geometry.setAttribute(
  'uv',
  new THREE.BufferAttribute(uvs, 2)
);

// 创建动画效果
function animate() {
  const positions = geometry.attributes.position.array;
  const time = performance.now() * 0.001;
  
  for (let i = 0; i < positions.length; i += 3) {
    positions[i + 1] = Math.sin(
      positions[i] * 0.5 + positions[i+2] * 0.3 + time
    ) * 1.5;
  }
  
  geometry.attributes.position.needsUpdate = true;
}

6. 常见问题解决方案

6.1 内存管理

// 正确释放内存
function disposeGeometry(geometry) {
  geometry.dispose();
  geometry.attributes.position.array = null;
  geometry = null;
}

6.2 顶点更新优化

// 使用共享ArrayBuffer
const sharedBuffer = new ArrayBuffer(1024 * 1024);
const positions = new Float32Array(sharedBuffer);
const normals = new Float32Array(sharedBuffer);

7. 完整应用案例

// 创建参数化圆柱体
function createCylinder(radiusTop, radiusBottom, height, radialSegments) {
  const geometry = new THREE.BufferGeometry();
  const vertices = [];
  const uvs = [];

  // 生成侧面顶点
  for (let y = 0; y <= 1; y++) {
    const radius = y ? radiusTop : radiusBottom;
    for (let i = 0; i <= radialSegments; i++) {
      const angle = (i / radialSegments) * Math.PI * 2;
      vertices.push(
        Math.cos(angle) * radius,
        height * (y - 0.5),
        Math.sin(angle) * radius
      );
      uvs.push(i / radialSegments, y);
    }
  }

  // 设置几何属性
  geometry.setAttribute(
    'position',
    new THREE.BufferAttribute(new Float32Array(vertices), 3)
  );
  geometry.setAttribute(
    'uv',
    new THREE.BufferAttribute(new Float32Array(uvs), 2)
  );

  return geometry;
}

掌握BufferGeometry的使用可以显著提升Three.js应用的性能表现,特别适用于以下场景:

  • 大数据量可视化(如地图、分子模型)
  • 动态几何体(实时变形、粒子系统)
  • 程序化生成模型(参数化建模)
  • WebXR等高性能要求场景

建议通过实际项目加深理解,可以先从修改现有几何体参数开始,逐步尝试完整几何体创建流程。

🌟 正确管理深层嵌套的 React 组件

作者 fengjutian
2025年4月1日 18:03

在 React 应用开发中,深层嵌套的组件结构如果处理不当,很容易变得难以维护。

本文将探讨一种架构模式,确保在处理嵌套组件时,提高应用的可扩展性、可维护性和代码清晰度。这种模式遵循单一职责原则,让子组件专注于自身的逻辑,而父组件则负责处理所有外部操作。

👀 查看演示

👉 演示

📂 查看完整项目

👉 GitHub 仓库

🏗️ 深层嵌套组件的问题

  • 组件紧密耦合:子组件处理了本不应由它们处理的操作。
  • 调试困难:业务逻辑分散在多个组件中。
  • 可复用性降低:组件难以提取和复用。

🎯 目标

我们希望实现以下目标:

  • ✅ 保持子组件纯净(只关注 UI 和内部状态)。
  • ✅ 将逻辑集中到父组件(处理如保存到数据库等外部操作)。
  • ✅ 使用验证工具确保数据完整性。

🛠️ 实现模式

父组件:处理所有操作

父组件负责以下任务:

  1. 管理应用状态。
  2. 在保存前验证数据。
  3. 处理来自子组件的更新。
import { useCallback, useState } from "react";
import { toast } from "react-toastify";
import { Data } from "../data/Data";
import { Child1 } from "./child1";
import { Child2 } from "./child2";


export const Parent = () => {
  const [data, setData] = useState<Data | undefined>({
    child1: undefined,
    child2: undefined,
    grandChild: undefined,
  });

  const isDataComplete = useCallback(
    (incomingData: Partial<Data> | undefined): incomingData is Data => {
      return (
        !!incomingData?.child1 &&
        incomingData?.child1.trim().length > 0 &&
        !!incomingData?.child2 &&
        !!incomingData?.grandChild
      );
    },
    []
  );

  const onSave = useCallback(
    (data: Partial<Data> | undefined) => {
      if (!isDataComplete(data)) {
        toast("Please fill all fields first", { style: { color: "black", backgroundColor: "#f9b6af" } });
        return;
      }
      toast("You filled all your fields!", { style: { color: "black", backgroundColor: "lightgreen" } });
    },
    [isDataComplete]
  );

  const onUpdate = useCallback(
    (incomingData: Partial<Data>) => {
      setData(prev => ({ ...prev, ...incomingData }));
    },
    []
  );

  return (
    <>
      <Child1 data={data} onUpdate={onUpdate} />
      <Child2 data={data} onUpdate={onUpdate} onSave={onSave} />
    </>
  );
};

子组件 1:委托更新

子组件仅管理自身的输入,并将更新委托给父组件。

import { useState } from "react";
import { Data } from "../data/Data";

export const Child1 = ({ data, onUpdate }: { data: Data | undefined; onUpdate: (parentData: Partial<Data>) => void; }) => {
  const [child1Input, setChild1Input] = useState(data?.child1);

  return (
    <>
      <label>Child 1 input</label>
      <input
        value={child1Input}
        onChange={(e) => {
          setChild1Input(e.target.value);
          onUpdate({ child1: e.target.value });
        }}
        />
    </>
  );
};

子组件 2:嵌套子组件和保存操作

孙子组件使用委托逻辑进行按钮操作。

import { useState } from "react";
import { GrandChild } from "./grandChild";

export const Child2 = ({ data, onUpdate, onSave }) => {
  const [child2Input, setChild2Input] = useState(data?.child2);

  return (
    <>
      <label>Child 2 input</label>
      <input
        value={child2Input}
        onChange={(e) => {
          setChild2Input(e.target.value);
          onUpdate({ child2: e.target.value });
        }}
        />
      <GrandChild onUpdate={onUpdate} data={data} onSave={onSave} />
    </>
  );
};

自定义逻辑和父组件逻辑

import { useState } from "react";

export const GrandChild = ({ onUpdate, data, onSave }) => {
  const [grandChildInput, setGrandChildInput] = useState(data?.grandChild);

  return (
    <>
      <label>Grandchild input</label>
      <input
        value={grandChildInput}
        onChange={(e) => {
          setGrandChildInput(e.target.value);
          onUpdate({ grandChild: e.target.value });
        }}
        />
      <button onClick={() => onSave(data)}>Save</button>
    </>
  );
};

如果子组件或孙子组件在更新父组件之前需要包含自定义逻辑,可以定义一个自定义回调,然后调用委托的父组件操作。例如:

jsxconst customOnUpdate = (value: string, onUpdate: (data: Partial<Data>) => void) => {
  console.log("Custom logic before updating:", value);

  // Then execute parent logic
  onUpdate();

  console.log("Custom logic after updating:", transformedValue);
};

🎯 为什么这样有效

  • 关注点分离:子组件仅处理自身的逻辑。
  • 单一数据源:父组件管理并验证数据。
  • 提高可复用性:任何组件都可以独立复用。
  • 更好的可维护性:调试和扩展功能更加容易。

🚀 编程愉快!

原文:www.yuque.com/fengjutian/… 《🌟 Managing Deeply Nested React Components the Right Way》

使用react对接百度语音实时识别

2025年4月1日 17:49

最近公司需要做一个ai对话网站,有语音输入的需求,对接百度实时语音识别,记录下实现过程

直接上干货

因为用的react,一开始我就想到了hooks,自定义一个useBaiduAsr方法,透出text start send end参数,其中text是识别后的文字,其余都是方法,可针对指定场景来调用

import { useCallback, useEffect, useRef, useState } from 'react'

export const useBaiduAsr = (apiKey, appId) => {
  const [text, setText] = useState('')
  const [resultObjs, setResultObjs] = useState([])
  const wsRef = useRef(null)

  // 开始语音识别
  const startStreamingRecognition = useCallback(async () => {
    try {
      const uuid = crypto.randomUUID()
      const wsUrl = `wss://vop.baidu.com/realtime_asr?sn=${uuid}`
      wsRef.current = new WebSocket(wsUrl)

      wsRef.current.onmessage = (event) => {
        try {
          const data = JSON.parse(event.data)
          if (data.type === "MID_TEXT" || data.type === "FIN_TEXT") {
            const { result, start_time } = data
            setResultObjs(pre => [...pre, { result, start_time }])
          }
        } catch (error) {
          console.error('处理消息时发生错误:', error)
        }
      }

      return new Promise((resolve, reject) => {
        wsRef.current.onopen = () => {
          console.log('WebSocket连接已建立')
          const startParams = {
            type: 'START',
            data: {
              appid: appId,
              appkey: apiKey,
              dev_pid: 1537,
              format: "pcm",
              sample: 16000,
              cuid: 'react-app'
            }
          }
          wsRef.current.send(JSON.stringify(startParams))
          resolve(wsRef.current)
        }

        wsRef.current.onerror = (error) => {
          console.error('WebSocket错误:', error)
          reject(error)
        }

        wsRef.current.onclose = () => {
          console.log('WebSocket连接已关闭')
        }
      })
    } catch (error) {
      console.error('创建WebSocket连接失败:', error)
      throw error
    }
  }, [apiKey])

  // 发送音频数据
  const sendAudioData = (audioData) => {
    if (!wsRef.current) {
      console.error('WebSocket未连接')
      return
    }
    if (wsRef.current.readyState === WebSocket.OPEN) {
      wsRef.current.send(audioData)
    } else {
      // console.error('WebSocket未就绪,当前状态:', wsRef.current.readyState)
    }
  }

  // 停止语音识别
  const stopStreamingRecognition = useCallback(() => {
    if (!wsRef.current) {
      return
    }
    try {
      const finishParams = {
        type: 'FINISH'
      }
      wsRef.current.send(JSON.stringify(finishParams))
      wsRef.current.close()
      setText('')
      setResultObjs([])
    } catch (error) {
      console.error('关闭WebSocket时发生错误:', error)
    }
  }, [])

  useEffect(() => {
    const result = []
    let tempGroup = []
    resultObjs.forEach((item, index) => {
      if (index === 0 || item.start_time === resultObjs[index - 1].start_time) {
        tempGroup.push(item.result)
      } else {
        result.push(tempGroup)
        tempGroup = [item.result]
      }
    })
    if (tempGroup.length) {
      result.push(tempGroup)
    }
    if (result.length) {
      const text = result.map(item => item[item.length - 1]).join('')
      setText(text)
    }
  }, [resultObjs])

  return {
    text,
    start: startStreamingRecognition,
    send: sendAudioData,
    stop: stopStreamingRecognition,
  }
} 

解析下代码: 百度语音实时识别使用ws来连接,在连接成功后需要手动触发一次开始任务参数,在识别成功后会返回一个识别内容对象,但是内容可能会重复,百度的策略是文字累加的,那我们该如何消费生成的文字?

我的思路是使用resultObjs数组来接收返回的内容,如果语音被识别为连续的一段话,他们的start_time字段是相同的,通过这个可将相同的start_time的result放到一个二维数组中,下层每个数组代表一个段落,段落数组中每个元素代表识别过程中的内容,我们直接取最后一个即可,这样有个好处就是,实时识别后等最后拿到结果有一个纠正的效果。

[
  ['你', '你好', '你好啊', '你好啊!' ],
  ['我', '我可', '我可以', '我可以和你', '我可以和你一起吗?']
]

将最后一个取出后拼接在一起即可 下面看我们的组件如何消费这个hook

import { Button } from 'antd'
import React, { useEffect, useRef, useState } from 'react'

import { useBaiduAsr } from '@/hooks/useBaiduAsr'

import IconFont from '../IconFont'
import css from './index.module.scss'

const VoiceRecognition = ({ onStop, onTextChange }) => {
  const [isRecording, setIsRecording] = useState(false)
  const mediaStreamRef = useRef(null)
  const audioProcessorRef = useRef(null)
  const { text, start, send, stop } = useBaiduAsr(YOUR_API_KEY, YOUR_APP_ID)

  const startRecording = async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
      const audioContext = new AudioContext({ sampleRate: 16000 })
      const source = audioContext.createMediaStreamSource(stream)
      const processor = audioContext.createScriptProcessor(4096, 1, 1)

      source.connect(processor)
      processor.connect(audioContext.destination)
      // 初始化百度流式识别
      await start()

      processor.onaudioprocess = (e) => {
        try {
          const audioData = e.inputBuffer.getChannelData(0)
          const pcmData = float32ToPCM(audioData)
          send(pcmData)
        } catch (e) {
          console.log('send error', e)
          stopRecording()
        }
      }

      mediaStreamRef.current = stream
      audioProcessorRef.current = processor
      setIsRecording(true)
    } catch (err) {
      console.log(err)
    }
  }

  const stopRecording = async () => {
    onStop?.()
    stop()
    if (audioProcessorRef.current) {
      audioProcessorRef.current.disconnect()
      audioProcessorRef.current = null
    }

    if (mediaStreamRef.current) {
      mediaStreamRef.current.getTracks().forEach((track) => track.stop())
      mediaStreamRef.current = null
    }

    setIsRecording(false)
  }

  // 转换Float32音频数据为Int16
  const float32ToPCM = (float32Array) => {
    const pcm16Array = new Int16Array(float32Array.length)
    for (let i = 0; i < float32Array.length; i++) {
      pcm16Array[i] = Math.max(-32768, Math.min(32767, float32Array[i] * 32768))
    }
    return pcm16Array.buffer
  }

  useEffect(() => {
    return () => {
      stopRecording()
    }
  }, [])

  useEffect(() => {
    onTextChange(text)
  }, [text, onTextChange])

  return (
    <>
      {isRecording ? (
        <Button type="text" size="middle" className={css['voice-btn']} onClick={stopRecording}>
          <IconFont className={css['search-voice-listen']} type="icon-mic-off" />
        </Button>
      ) : (
        <Button type="text" size="middle" className={css['voice-btn']} onClick={startRecording}>
          <IconFont className={css['search-voice']} type="icon-mic-on" />
        </Button>
      )}
    </>
  )
}

export default VoiceRecognition

这里的重点是关于语音切片的一些逻辑和api调用以及音频的格式转换,我就不赘述了,你们自己看代码就好,在消费这个组件时,需要注意传入的onStop方法中需要把text赋值到input的value中

export defualt function Chat() {
    const [prompt, setPrompt] = useState('')
    const [voiceText, setVoiceText] = useState('')
    
    const handleVoiceStop = () => {
        setPrompt(pre => pre + voiceText)
        setVoiceText('')
    }
    
    const handleText = (text) => {
       setVoiceText(text)
    }
    return (
        <Input 
            value={prompt + voiceText}
            suffix={<VoiceRecognition onStop={handleVoiceStop} onTextChange={handleText} />} 
        />
    )
}

目前暂时就是这样,后面还有ws断线重连和语音续接的需求,后续再更新了。

vue3 element-plus 二次封装Drawer抽屉,关闭时添加二次对话,开箱即用

作者 Mintopia
2025年4月1日 17:41

背景

在生产中我们经常会遇到一些基于UI库二次封装的场景,我认为二次封装对于老手来说没有什么难点,只不过是业务上的变化,但是对于新手或者其他框架的开发者,不免有些抓耳挠腮,我呢又恰巧有机会和时间,就留一些文章在这里供有需要的人互相参考和大家一起讨论。

需求描述

image.png

如上图所示,表格,抽屉,确认框,在用户意外关闭的时候进行提示,正常提交的时候不需要提示

实现步骤

第一,我们要整理关键线索,从element-plus文档中可以看到before-close 点击mask关闭时会触发,@close 弹框关闭时会触发,有了以上线索我们就可以进行二次封装了,那么为了实用方便,我们要尽量把二次封装做的像普通drawer使用。

下面跟着我的思路来实现一下

新建Drawer.vue文件

<script lang="ts" setup>
import { ElMessageBox } from 'element-plus'
// 是否显示弹框
const showModal = ref(false)

const props = defineProps({
  // 控制是否提示
  closeConfirm: propTypes.bool.def(false)
})

const emit = defineEmits(['update:modelValue'])
// 封装确认弹框
const showConfirm = async () => {
  if (!props.closeConfirm) return true
  try {
    await ElMessageBox.confirm('确定要关闭吗?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
    return true
  } catch {
    return false
  }
}

/** 是否是关闭拦截 */
const isBeforeClose = ref(false)
// 关闭拦截
const handleBeforeClose = async (done: () => void) => {
  const shouldClose = await showConfirm()
  if (shouldClose) {
    // 设置拦截标志
    isBeforeClose.value = true
    // 放开拦截
    done()
    // 关闭弹窗
    handleShowModal(false)
  }
}
// 点击Icon 关闭
const handleClose = async () => {
  const shouldClose = await showConfirm()
  if (shouldClose) {
    // 设置拦截标志
    isBeforeClose.value = true
    // 关闭弹窗
    handleShowModal(false)
  }
}

// 监听 modelValue 的变化
watch(
  () => props.modelValue,
  async (newVal) => {
    if (newVal) {
      // 打开表格
      handleShowModal(true)
      return
    }
    // 当外部绑定变量设置关闭,并且拦截标志为false,则通过监听拦截
    if (!newVal && !isBeforeClose.value) {
      isBeforeClose.value = true
      // 等待弹框验证
      const shouldClose = await showConfirm()
      // 验证为确定,模态框关闭
      handleShowModal(!shouldClose)
    }
    // 每次状态变化都还原拦截默认值
    isBeforeClose.value = false
  }
)
// 打开弹窗方法,同步外部响应式变量
const handleShowModal = (value: boolean) => {
  // 打开表格
  showModal.value = value
  // 同步外部变量
  emit('update:modelValue', value)
}
</script>

<template>
  <ElDrawer
    :model-value="showModal"
    :close-on-click-modal="true"
    destroy-on-close
    lock-scroll
    :show-close="false"
    :before-close="handleBeforeClose"
  >
    <template #header>
      <div class="relative h-54px flex items-center justify-between pl-15px pr-15px">
        <slot name="title">
          {{ title }}
        </slot>
        <div
          class="absolute right-15px top-[50%] h-54px flex translate-y-[-50%] items-center justify-between"
        >
          <Icon
            class="is-hover cursor-pointer"
            icon="ep:close"
            hover-color="var(--el-color-primary)"
            color="var(--el-color-info)"
            @click="handleClose"
          />
        </div>
      </div>
    </template>

    <ElScrollbar v-if="scroll" :style="dialogStyle">
      <slot></slot>
    </ElScrollbar>
    <slot v-else></slot>

    <template v-if="slots.footer" #footer>
      <slot name="footer"></slot>
    </template>
  </ElDrawer>
</template>


上面就完成了二次封装的组件,下面去使用

在使用之前,创建一个hooks组件来应付一般场景,创建useDrawer.ts文件

// 抽屉控制变量显示
export const useDrawerFlag = (title: string = "") => {
    // 弹框标题
    const dialogTitle = ref(title)
    // 抽屉显示控制
    const dialogVisible = ref(false)
    // 关闭时是否检查确认框
    const closeConfirm = ref(true)
    // 确认抽屉关闭并取消提示
    const ConfirmDrawerVisible = () =>{
        closeConfirm.value = false
        dialogVisible.value = false
        // 异步还原弹框检测默认值
        setTimeout(()=>{
            closeConfirm.value = true
        },500)
    }

    return {
        dialogVisible,
        dialogTitle,
        closeConfirm,
        ConfirmDrawerVisible
    }
}

有了这个文件,我们就可以统一使用二次封装好的组件,和必要的参数方法,为什么封装了ConfirmDrawerVisible方法?

qaq: 因为我们的drawer里面可能会放表单,一般表单会需要验证,通过后直接关闭,不需要二次确认,所以有了这个方法的封装,那接下来看下使用的代码

使用代码

<template>
  <Drawer :title="dialogTitle" v-model="dialogVisible" :closeConfirm="closeConfirm">
    <el-form
      ref="formRef"
      :model="formData"
      :rules="formRules"
      label-width="100px"
      v-loading="formLoading"
    >
      <el-form-item label="父分类id" prop="parentId">
        <el-tree-select
          v-model="formData.parentId"
          :data="categoryTree"
          :props="defaultProps"
          check-strictly
          default-expand-all
          placeholder="请选择父分类id"
        />
      </el-form-item>
    </el-form>
    <template #footer>
      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
      <el-button @click="dialogVisible = false">取 消</el-button>
    </template>
  </Drawer>
</template>
<script setup lang="ts">
import { useDrawerFlag } from '@/hooks/web/useDrawer'

// 使用hooks封装
const { closeConfirm, dialogVisible, dialogTitle, ConfirmDrawerVisible } = useDrawerFlag()

/** IOT产品分类 表单 */
defineOptions({ name: 'CategoryForm' })

const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗

const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
const formType = ref('') // 表单的类型:create - 新增;update - 修改
const formData = ref({
  id: undefined,
  parentId: undefined,
  name: undefined,
  sort: undefined,
  status: undefined,
  imgUrl: undefined
  // isSys: undefined
})
const formRules = reactive({
  parentId: [{ required: true, message: '父分类id不能为空', trigger: 'blur' }],
  name: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }],
  status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }]
  // isSys: [{ required: true, message: '是否系统通用不能为空', trigger: 'blur' }]
})

const formRef = ref() // 表单 Ref
const categoryTree = ref() // 树形结构

/** 打开弹窗 */
const open = async (type: string, id?: number) => {
  dialogVisible.value = true
  dialogTitle.value = t('action.' + type)
  formType.value = type
  resetForm()
  // 修改时,设置数据
  if (id) {
    formLoading.value = true
    try {
      formData.value = await CategoryApi.getCategory(id)
    } finally {
      formLoading.value = false
    }
  }
  await getCategoryTree()
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗

/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
  // 校验表单
  await formRef.value.validate()
  // 提交请求
  formLoading.value = true
  try {
    const data = formData.value as unknown as CategoryVO
    if (formType.value === 'create') {
      await CategoryApi.createCategory(data)
      message.success(t('common.createSuccess'))
    } else {
      await CategoryApi.updateCategory(data)
      message.success(t('common.updateSuccess'))
    }
    // 关闭弹框
    ConfirmDrawerVisible()
    // 发送操作成功的事件
    emit('success')
  } finally {
    formLoading.value = false
  }
}

/** 重置表单 */
const resetForm = () => {
  formData.value = {
    id: undefined,
    parentId: undefined,
    name: undefined,
    sort: undefined,
    status: undefined,
    imgUrl: undefined
    // isSys: undefined
  }
  formRef.value?.resetFields()
}
</script>

总结

看这样就可以到处去用了,虽然是一个小功能,但是里面也需要花时间协调逻辑,如果你觉得有用,请三连~~

超级简单,Trae 开发Chrome浏览器插件,AI编程时代来了

2025年4月1日 17:40
  1. 配套视频

www.bilibili.com/video/BV12L…

  1. 项目源码

需要下载源码,请加:vme99520 后私发你呀:

image.png

  1. 提示词合集

提示词:

开发一款chrome浏览器插件,打开插件,插件的界面上显示:悟空非空也 5 个汉字

Trae AI

我会帮你开发一个简单的Chrome浏览器插件。这个插件需要包含一个manifest.json文件来声明插件的基本信息,一个popup.html作为插件的主界面来显示文字,以及相关的样式文件。主界面将采用简洁的设计,居中显示"悟空非空也"这5个汉字,并添加适当的字体样式和大小来确保良好的显示效果。我会确保插件结构清晰,便于后续维护和扩展。

首先查看当前工作目录的结构,确保目录为空,以便创建Chrome插件所需的文件。

创建manifest.json文件,定义Chrome插件的基本配置信息,包括名称、版本、权限等。

创建popup.html文件,设计插件的主界面,使用CSS样式让文字居中显示,并设置合适的字体和大小。

创建插件图标文件,使用SVG格式制作一个简洁的图标,包含"悟"字,作为插件的标识。

创建48x48尺寸的插件图标,保持与16x16图标相同的设计风格。

创建128x128尺寸的插件图标,完成插件的所有必需文件。

已完成Chrome浏览器插件的开发,主要完成了以下内容:

  • 创建了manifest.json文件,配置了插件的基本信息,包括名称、版本和描述
  • 开发了popup.html作为插件的主界面,使用了flex布局使文字居中显示
  • 设计了简洁美观的界面,使用了合适的字体和大小来显示"悟空非空也"五个汉字
  • 创建了三种尺寸(16x16、48x48、128x128)的插件图标,使用SVG格式制作,保持了统一的设计风格

插件开发完成后,用户可以通过Chrome浏览器的开发者模式加载此插件,点击插件图标即可看到"悟空非空也"五个汉字的优雅展示。

提示词:

给主界面添加一个美观的样式

Trae AI

我会帮你优化popup.html的样式,让界面更加美观。主要从以下几个方面进行改进:调整字体大小和字重以提升可读性,添加优雅的背景渐变色,增加适当的阴影效果,优化文字间距,并添加平滑的悬停动画效果。这些改进将使界面既保持简洁又富有现代感。

我将优化界面样式,添加渐变背景、文字阴影和悬停动画效果,使界面更加现代化和美观。

已完成界面样式的优化,主要改进包括:

  • 调整了窗口尺寸,使界面更加宽敞
  • 添加了优雅的渐变背景色
  • 增大了字体尺寸并调整了字重,提升可读性
  • 添加了文字阴影和字间距,使文字更加清晰美观
  • 为文字容器添加了半透明背景和模糊效果
  • 实现了平滑的悬停动画效果,增加了交互体验

这些改进使界面既保持了简洁的风格,又增添了现代感和精致感。

提示词:

开发一款chrome浏览器插件,界面的风格类似苹果的便笺软件,主要功能有:记录文字信息,自动保存,支持markdown格式

Trae AI

我会帮你开发一个类似苹果便笺的Chrome浏览器插件。这个插件将采用现代化的UI设计,主要包含以下核心功能:文本编辑器支持Markdown格式,实现自动保存机制,以及一个简洁优雅的用户界面。我会先创建基本的插件结构,包括manifest.json配置文件、popup页面和后台脚本,然后实现编辑器组件和数据持久化功能。为了实现类似苹果便笺的界面风格,我们将使用CSS来模仿其简约设计风格,包括字体、颜色和布局等细节。

首先查看项目目录结构,确保我们有一个清晰的开发环境。

创建Chrome插件的基本配置文件manifest.json,定义插件的名称、版本、权限等基本信息。

创建插件图标文件夹和SVG图标,使用简约的设计风格。

创建popup.html作为插件的主界面,设计一个类似苹果便笺的简约界面,包含Markdown编辑器和自动保存功能。

创建popup.js实现编辑器的核心功能,包括Markdown支持、自动保存和数据同步。

已成功创建了一个类似苹果便笺风格的Chrome浏览器插件。主要完成了以下功能:

  • 创建了manifest.json配置文件,定义了插件的基本信息和所需权限
  • 设计了简约风格的插件图标
  • 实现了类似苹果便笺的界面设计,包括字体、颜色和布局
  • 开发了支持Markdown格式的文本编辑器
  • 实现了自动保存功能,确保用户的笔记内容不会丢失
  • 添加了状态提示,显示保存状态
  • 优化了编辑体验,支持Tab键缩进

插件采用了现代化的UI设计,界面简洁优雅,完全符合用户需求。用户可以随时记录文字信息,支持Markdown格式,所有内容都会自动保存到Chrome的存储中。

提示词:

manifest.json 8-12 manifest.json 15-19 该三个图标没有生成,请修复

Trae AI:

我注意到图标文件缺失的问题。我会先检查icons目录下是否存在icon.svg文件,然后基于这个svg文件生成三个不同尺寸(16x16、48x48、128x128)的png图标文件。如果svg文件不存在,我会先创建一个简约风格的svg图标,再生成对应的png文件。这些图标将采用类似苹果便笺的设计风格,使用简洁的线条和柔和的颜色。

首先检查icons目录的内容,确认是否存在icon.svg文件。

检查现有的icon.svg文件内容,确保它是一个有效的SVG图标文件。

基于现有的SVG图标,创建一个更适合Chrome扩展使用的SVG文件,包含更清晰的视口定义和更好的颜色方案。

创建48x48尺寸的图标文件,保持与16x16图标相同的设计风格,但调整尺寸和细节以确保在更大分辨率下的清晰度。

创建128x128尺寸的图标文件,这是最大尺寸的图标,需要确保在高分辨率显示器上也能保持清晰度。

已完成Chrome扩展图标的创建工作。基于现有的SVG图标设计,创建了三个不同尺寸的图标文件:

  • icons/icon16.png (16x16)
  • icons/icon48.png (48x48)
  • icons/icon128.png (128x128)

所有图标都采用了简约的便笺设计风格,使用温暖的橙色调(#FFB340作为主色,#FFD68A作为次要色),确保在不同尺寸下都能保持清晰度和一致性。图标设计包含了圆角矩形的便笺外形,以及简化的文本线条,完美符合Chrome扩展的图标规范。

提示词:

请修复图标

Trae AI:

我看到项目中已经有icons目录,并且存在icon.svg源文件。我会基于这个svg文件生成三个不同尺寸的png图标文件(16x16、48x48和128x128),采用类似苹果便笺的设计风格,使用简约的线条和柔和的颜色。这些图标将保持清晰度和一致性,确保在Chrome浏览器的不同场景下都能完美显示。

首先查看当前的icon.svg文件内容,以了解现有图标的设计并进行优化。

优化SVG图标设计,采用更现代的便笺风格,使用柔和的颜色和圆角设计。

优化了扩展程序的图标设计:

  • 重新设计了icon.svg文件,采用了更现代的便笺风格
  • 使用了柔和的米色渐变背景(从FFE4B5到FFDAB9)
  • 添加了圆角设计和细微的边框效果
  • 优化了文本线条的显示效果,使用了圆角线帽
  • 整体设计更符合现代UI审美,保持了简约性的同时提升了视觉表现

提示词:

icon16.png icon48.png icon128.png 三个图片无法打开,请修复

Trae AI:

我注意到项目中的三个PNG图标文件存在问题,需要基于现有的SVG源文件重新生成这些图标。我会将SVG文件转换为对应尺寸(16x16、48x48和128x128)的PNG格式,确保图标能够正确显示在Chrome浏览器的不同场景中。图标设计将保持苹果便笺风格,使用温暖的橙色调和简约的线条设计。

发现图标文件存在问题,我将使用SVG源文件重新创建图标。首先创建icon.svg作为基础图标文件。

检测到icon.svg文件已存在,我将重写它以确保内容正确,然后用它来生成不同尺寸的PNG图标。

现在我们需要使用命令行工具将SVG转换为不同尺寸的PNG图标。我们将使用npm安装并运行svg2png工具来完成这个转换。

npm install -g svg2png-cli

看起来没有权限全局安装npm包。让我们改用本地安装svg2png-cli,并初始化一个package.json文件。

npm init -y

现在我们在项目中本地安装svg2png-cli包,这样就不需要全局安装权限了。

npm install --save-dev svg2png-cli

看来svg2png-cli安装遇到问题,让我们尝试使用另一个更可靠的工具:sharp。这是一个高性能的Node.js图像处理库,可以帮助我们完成SVG到PNG的转换。

npm install --save-dev sharp

创建一个Node.js脚本来使用sharp库将SVG转换为不同尺寸的PNG图标。

现在运行转换脚本,将SVG文件转换为所需的三种尺寸的PNG图标。

node convert-icons.js

我们成功修复了Chrome扩展的图标问题。具体完成了以下工作:

  • 重写了基础SVG图标文件,确保其包含正确的样式和尺寸信息
  • 创建了一个Node.js转换脚本(convert-icons.js)用于图标转换
  • 使用sharp库将SVG文件成功转换为三个不同尺寸(16x16、48x48、128x128)的PNG图标
  • 生成的PNG图标保持了原有的设计风格,使用温暖的橙色调和简约的线条设计

所有图标文件现在都已正确生成,可以在Chrome浏览器中正常显示。

前端文件下载全攻略:从单文件到批量下载,哪种方法最优?

作者 Lazy_zheng
2025年4月1日 17:35

小张是一名刚入职的前端开发工程师,某天,他的领导给他布置了一个看似简单的任务:

让用户能够通过文件链接下载多个文件

小张信心满满,觉得这不过是个小问题。然而,当他真正动手时,才发现这个需求并不简单。不同的下载方式各有优缺点,甚至有些方法会带来意想不到的问题,他决定一一尝试,探索最优解。

方案一:window.open——简单粗暴,但会打开新标签页

小张首先想到的是 window.open(url),它可以让浏览器直接打开下载链接。

window.open('https://example.com/file.pdf');

优点

  • 代码简单,直接调用即可。
  • 适用于单个文件的下载。

缺点

  • 每次下载都会打开一个新的浏览器标签页,影响用户体验。
  • 部分浏览器可能会拦截 window.open,导致下载失败。

方案二:window.location.href 简单有效,但不能同时下载多个文件

小张发现,window.location.href 也可以实现下载,且不会打开新标签页。

window.location.href = 'https://example.com/file.pdf';

优点

  • 适用于单文件下载。
  • 不会像 window.open 那样打开新页面。

缺点

  • 无法循环下载多个文件。如果连续多次赋值 window.location.href,后一个请求会覆盖前一个,导致只能下载最后一个文件。

方案三:iframe 支持多文件下载,但无法监听完成状态

为了让多个文件能够顺利下载,小张尝试用 iframe

function downloadFile(url) {
  const iframe = document.createElement('iframe');
  iframe.style.display = 'none';
  iframe.src = url;
  document.body.appendChild(iframe);
  setTimeout(() => {
    document.body.removeChild(iframe);
  }, 5000); // 延迟移除 iframe,防止影响下载
}

优点

  • 适用于多文件下载。

缺点

  • iframe 无法监听文件下载是否完成。
  • 需要在合适的时机移除 iframe,否则可能会影响页面性能。

方案四:fetch + blob——最优雅的下载方式

小张最终发现,fetch 可以获取文件数据,再通过 Blob 处理并使用 a 标签下载。

async function downloadFile(url, fileName) {
  const response = await fetch(url);
  if (!response.ok) throw new Error('Download failed');
  const blob = await response.blob();
  const blobUrl = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = blobUrl;
  a.download = fileName;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(blobUrl);
}

function download(fileList){
   for(const file of fileList) {
     await downloadFile(file.url,file.name)
   }
}

优点

  • 不会打开新标签页。
  • 可以同时下载多个文件。
  • 适用于现代浏览器,兼容性较好。

缺点

  • 需要处理异步 fetch 请求。
  • 服务器必须支持跨域资源共享(CORS),否则 fetch 请求会失败。
  • 多次文件下载会导致多个浏览器下载图标:每次调用 a.click() 时,浏览器都会显示一个下载图标,影响用户体验。

方案五:jsZip 打包多个文件为 ZIP 下载——避免多次下载图标

为了进一步优化方案四,避免浏览器每次下载时显示多个下载图标,小张决定使用 jsZip 插件将多个文件打包成一个 ZIP 文件下载。

import JSZip from 'jszip';

async function downloadFilesAsZip(files) {
  const zip = new JSZip();
  // 循环遍历多个文件,获取每个文件的数据
  for (const file of files) {
    const response = await fetch(file.url);
    if (!response.ok) throw new Error(`Failed to fetch ${file.name}`);
    const blob = await response.blob();
    zip.file(file.name, blob); // 将文件添加到 ZIP 包中
  }

  // 生成 ZIP 文件并触发下载
  zip.generateAsync({ type: "blob" })
    .then(function(content) {
      const a = document.createElement('a');
      const blobUrl = URL.createObjectURL(content);
      a.href = blobUrl;
      // 给压缩包设置下载文件名
      a.download = 'files.zip';
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      // 释放 URL 对象
      URL.revokeObjectURL(blobUrl);
    });
}

优点

  • 提升用户体验:用户下载一个压缩包后,只需解压就可以获取所有文件,避免了多次点击和等待的麻烦。
  • 适用于多文件下载:非常适合需要批量下载的场景。

缺点

  • 浏览器对大文件的支持:如果要下载的文件非常大,或者文件总大小很大,可能会导致内存消耗过高,甚至在浏览器中崩溃。
  • 下载速度受限于压缩处理:打包文件为 ZIP 需要时间,尤其是文件较多时,会稍微影响压缩的速度,只适用于文件不是很大且数量不是很多的时候

结语:小张的最终选择

经过一番探索,小张最终选择了 jsZip 打包文件的方案,因为它不仅解决了多个文件下载时图标显示的问题,还提高了用户体验,让下载更加流畅,没有哪个方案比另外一个方案好,只有最适合的方案,根据实际的场景能满足需求最优解就是最好的。

Webpack Loader 执行机制

作者 ak啊
2025年4月1日 17:28

一、Loader 链式调用机制

Loader 的执行分为 Pitch 阶段Normal 阶段,两者共同构成链式调用逻辑。


1. Pitch 阶段
  • 执行顺序:从左到右(与 Normal 阶段相反)。
  • 核心作用:拦截机制。如果某个 Loader 的 pitch 方法返回非 undefined 值,直接跳过后续 Loader,进入 Normal 阶段的逆向执行。
  • 伪代码逻辑
    const result = loaderA.pitch(remainingRequest, previousRequest, data);
    if (result !== undefined) {
      // 跳过后续 Loader,进入 Normal 阶段逆向执行
    }
    
2. Normal 阶段
  • 执行顺序:从右到左。
  • 核心作用:实际处理文件内容,上一个 Loader 的输出是下一个 Loader 的输入。

二、源码转换流程(runLoaders 核心逻辑)

Webpack 使用 loader-runner 模块处理 Loader 链。以下是简化后的源码分析:

关键源码:runLoaders 函数(简化版)
function runLoaders(resource, loaders, context, callback) {
  const loaderContext = context || {};
  let loaderIndex = 0; // 当前执行的 Loader 索引
  let processOptions = {
    resourceBuffer: null,
    readResource: fs.readFile.bind(fs)
  };

  // 迭代执行 Pitch 阶段
  iteratePitchingLoaders(processOptions, loaderContext, (err, result) => {
    if (err) return callback(err);
    callback(null, ...result);
  });

  function iteratePitchingLoaders(options, loaderContext, callback) {
    if (loaderIndex >= loaders.length) {
      // 所有 Pitch 执行完毕,读取资源
      return processResource(options, loaderContext, callback);
    }

    const currentLoader = loaders[loaderIndex];
    const pitchFn = currentLoader.pitch;

    loaderIndex++; // 移动到下一个 Loader

    if (!pitchFn) {
      // 没有 pitch 方法,继续下一个 Loader
      return iteratePitchingLoaders(options, loaderContext, callback);
    }

    // 执行当前 Loader 的 pitch 方法
    pitchFn.call(
      loaderContext,
      loaderContext.remainingRequest,
      loaderContext.previousRequest,
      (currentLoader.data = {})
    ), (err, ...args) => {
      if (args.length > 0) {
        const hasResult = args.some(arg => arg !== undefined);
        if (hasResult) {
          // Pitch 返回结果,跳过后续 Loader,逆向执行 Normal
          loaderIndex--;
          iterateNormalLoaders(options, loaderContext, args, callback);
          return;
        }
      }
      // 继续下一个 Pitch
      iteratePitchingLoaders(options, loaderContext, callback);
    });
  }

  function processResource(options, loaderContext, callback) {
    // 读取原始资源内容
    options.readResource(loaderContext.resource, (err, buffer) => {
      const resourceBuffer = buffer;
      iterateNormalLoaders(options, loaderContext, [resourceBuffer], callback);
    });
  }

  function iterateNormalLoaders(options, loaderContext, args, callback) {
    if (loaderIndex < 0) {
      // 所有 Normal 阶段完成
      return callback(null, args);
    }

    const currentLoader = loaders[loaderIndex];
    const normalFn = currentLoader.normal || currentLoader;

    loaderIndex--; // 逆向执行

    // 执行当前 Loader 的 Normal 方法
    normalFn.call(loaderContext, args[0], (err, ...returnArgs) => {
      if (err) return callback(err);
      iterateNormalLoaders(options, loaderContext, returnArgs, callback);
    });
  }
}

三、执行流程详解

  1. Pitch 阶段从左到右执行

    • 依次调用每个 Loader 的 pitch 方法。
    • 若某个 pitch 返回结果,跳过后续 Loader,直接进入 Normal 阶段。
  2. 读取资源文件

    • 若所有 pitch 均未拦截,读取原始文件内容。
  3. Normal 阶段从右到左执行

    • 将资源内容传递给最后一个 Loader 处理,结果逆向传递。

四、典型使用案例

案例:自定义 Loader 链观察执行顺序

Loader 配置

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.txt$/,
        use: [
          './loaders/loaderA.js',
          './loaders/loaderB.js',
          './loaders/loaderC.js'
        ]
      }
    ]
  }
};

Loader 实现

// loaderA.js
module.exports = function(source) {
  console.log('[Normal A]');
  return source + '-A';
};
module.exports.pitch = function() {
  console.log('[Pitch A]');
};

// loaderB.js
module.exports = function(source) {
  console.log('[Normal B]');
  return source + '-B';
};
module.exports.pitch = function() {
  console.log('[Pitch B]');
  // 返回非 undefined 值,拦截后续 Loader
  return '拦截内容';
};

// loaderC.js
module.exports = function(source) {
  console.log('[Normal C]');
  return source + '-C';
};
module.exports.pitch = function() {
  console.log('[Pitch C]');
};

执行结果

[Pitch A]
[Pitch B]  // B 的 pitch 返回拦截内容,跳过后续 Pitch
[Normal B] // 进入 Normal 阶段,从 B 开始逆向执行
[Normal A]
最终结果: "拦截内容-B-A"

五、关键总结

  1. Pitch 拦截:通过 pitch 方法提前返回结果,优化构建流程。
  2. 执行方向
    • Pitch:从左到右。
    • Normal:从右到左(若未拦截)。
  3. 资源处理runLoaders 通过 iteratePitchingLoadersiterateNormalLoaders 实现链式调用。

如何从零实现一个todo list(1)

作者 牛马喜喜
2025年4月1日 17:28

一、开始前准备

        实现一个想要的功能前,可以先理清它的功能,最好画一张思维导图,再按着导图实现的话会轻松很多,好记性不如烂笔头

        如我们今天要实现的一个todo list的功能 那我的规划是这样的首先先实现网页版本->再实现用electron包装起来加入sqlite数据库操作的功能。

        实现一个TodoList前 我们首先观察windows 自带的Microsoft To Do的功能 画出导图

        这样就可以很清晰的看到我们要实现的功能 那功能的大概罗列出来以后我们就要开始想界面如何实现 可以对界面打一个草稿做一个原型 这个一个demo项目的话 我们就直接根据Microsoft To Do布局做参考。

二、技术选型

demo来说的话 我个人习惯敏捷开发出原型 比起先打磨样式的情况 我更喜欢先把功能实现

首先进行技术选型:vue+vuex+vueitfy(同时兼容移动端)

三、原型编写

既然是要实现一个Todo List 那我们就先把注意力放在核心功能上 开始编写

首先通过vue ui 或者 vue-cli 创建一个空项目

# 若vue命令报错的话 请先安装脚手架 npm install -g @vue/cli
# 根据上面的选型 我们闯建项目的时候 只需要保留Babel与vuex
vue create todo-list
# 等编译完毕后 进入文件夹
cd todo-list
# 安装我们需要的vuetify 为什么其他UI也兼容移动端要选择它呢?它有内置的css规范 可以少写很多css
cnpm install vuetify@v2-stable -S
cnpm install sass@~1.32 sass-loader@10.x deepmerge -D #不建议直接安装官方的命令 sass-loader太高会报错
# 进入vueitfy 官网 https://v2.vuetifyjs.com/zh-Hans/getting-started/installation/#nuxt-5b8988c5
# 找到webpack安装 要看完上面网址webpack安装

安装完后步骤

  1. 引入vuetify

  2. 删除HellowWorld.vue

  3. 删除App.vue不需要的东西

  4. 引入样式文件

    1. public/index.html下加入 但是加载很慢

    2.    <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
         <link href="https://cdn.jsdelivr.net/npm/@mdi/font@6.x/css/materialdesignicons.min.css" rel="stylesheet">
      
  5. 在components创建TaskList.vue的组件 并引入到App.vue里

  6. 下载需要的库vue-uuid、momentjs原型开发

原型开发

    <!--App.vue -->
    <template>
      <div
        id="app"
        class="v-application"
      >

        <v-card
          width="100%"
          flat
        >
          <v-system-bar
            color="#303F9F"
            dark
          >
            <v-spacer></v-spacer>

            <v-icon>mdi-window-minimize</v-icon>

            <v-icon>mdi-window-maximize</v-icon>

            <v-icon>mdi-close</v-icon>
          </v-system-bar>

          <v-toolbar
            color="#3F51B5"
            dark
          >
            <v-app-bar-nav-icon></v-app-bar-nav-icon>

            <v-toolbar-title>我的一天</v-toolbar-title>

            <v-spacer></v-spacer>

          </v-toolbar>

          <v-container>
            <task-list />
          </v-container>
        </v-card>
      </div>
    </template>

    <script>
    import TaskList from "./components/TaskList.vue";
    export default {
      name: "App",
      components: {
        TaskList,
      },
    };
    </script>

    <style lang="scss">
    #app {
      font-family: Avenir, Helvetica, Arial, sans-serif;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      color: #2c3e50;
      height: 100vh;
      overflow: auto;
    }
    </style>
    <!-- components/TaskList.vue -->
    <template>
      <div class="py-4">
        <v-text-field
          v-model="todo"
          counter="25"
          placeholder="请输入待办事项"
          label="待办事项"
          outlined
          dense
          persistent-placeholder
          @keydown.enter="addTodo"
        ></v-text-field>

        <v-list dense>
          <div
            v-for="list in lists"
            :key="list.name"
          >
            <template v-if="list.data.length">
              <v-subheader>{{list.name}}</v-subheader>
              <v-list-item
                v-for="(item, i) in list.data"
                :key="i"
                class="task-item mb-4"
                :class="{complete:item.complete}"
              >
                <v-list-item-avatar>
                  <v-checkbox
                    v-model="item.complete"
                    @change="forceUpdate"
                    v-if="refresh"
                  />
                </v-list-item-avatar>
                <v-list-item-content>
                  <v-list-item-title>{{item.title}}</v-list-item-title>
                </v-list-item-content>
                <v-list-item-action>
                  <v-icon
                    v-if="!item.complete"
                    @click="deleteItem(item.id)"
                  >mdi-close</v-icon>
                </v-list-item-action>
              </v-list-item>
            </template>
          </div>
        </v-list>
      </div>
    </template>

    <script>
    import { uuid } from "vue-uuid";
    export default {
      data() {
        return {
          // 当前完整列表
          current_list: [],
          // 待办事项输入值
          todo: "",
          // 这里处理渲染的时候v-checkbox值变动样式不刷新的问题
          refresh: true,
        };
      },
      computed: {
        // 为了能用v-for 渲染写的列表目录
        lists() {
          return [
            {
              name: "待办列表",
              data: this.current_list.filter((list_item) => !list_item.complete),
            },
            {
              name: "已完成",
              data: this.current_list.filter((list_item) => list_item.complete),
            },
          ];
        },
      },
      methods: {
        // 新增todolist
        addTodo() {
          if (!this.todo) return;
          this.current_list.push({
            // 增加id是因为使用了computed 它的index是不准的
            id: uuid.v4(),
            title: this.todo,
            complete: false,
            create_time: new Date(),
          });
          this.todo = "";
        },
        // 强制刷新
        forceUpdate() {
          this.refresh = false;
          this.$nextTick(() => {
            this.refresh = true;
          });
        },
        // 删除事项
        deleteItem(id) {
          this.current_list.splice(
            this.current_list.findIndex((item) => item.id == id),
            1
          );
        },
      },
    };
    </script>

    <style lang="scss" scoped>
    ::v-deep .v-label {
      left: -13px !important;
    }
    .task-item {
      background-color: #eee;
      border-radius: 10px;
    }
    .task-item.complete {
      .v-list-item__title {
        text-decoration: line-through;
        color: #ccc;
      }
    }
    </style>

        这样就完成了一个最基础的todolist的原型功能 后续我们开始对布局进行调整 把思维导图上的大模块上的功能加上去 我们就可以继续对原型功能开发

Vue编写一个自己的树形组件

作者 牛马喜喜
2025年4月1日 17:23

一、需求情况

    有时候用Vue开发的时候,不想使用组件库,但是遇到需要目录显示的情况的时候,想要一个树形递归的组件,可以自己封装定义想要的样式效果与事件,本文讲解如何实现一个自己的树形组件。

二、开发组件

Tree.vue父组件

    <!--
     * @Author: 羊驼
     * @Date: 2024-03-04 11:43:47
     * @LastEditors: 羊驼
     * @LastEditTime: 2024-03-12 17:16:58
     * @Description: 树形组件的树
    -->
    <template>
      <ul>
        <tree-node
          v-for="node in nodes"
          :key="node.id"
          :node="node"
          :select_id="select_id"
          @node-clicked="handleNodeClicked"
        ></tree-node>
      </ul>
    </template>
    <script>
    import TreeNode from "./TreeNode.vue";
    export default {
      components: {
        TreeNode,
      },
      props: {
        // 节点数据
        nodes: Array,
        // 当前选中的节点
        select_id: String | Number,
      },
      methods: {
        // 定义一个节点点击事件
        handleNodeClicked(clickedNode) {
          this.$emit("node-clicked", clickedNode);
        },
      },
    };
    </script>

    <style scoped>
    ul {
      list-style: none;
    }
    </style>

TreeNode 节点组件

    <!--
     * @Author: 羊驼
     * @Date: 2024-03-04 11:42:24
     * @LastEditors: 羊驼
     * @LastEditTime: 2024-03-12 17:19:30
     * @Description: 树形节点
    -->
    <template>
      <div>
        <li
          :class="{'a-selected':select_id==node.id}"
          class="flex"
          ref="text"
          @click="childClicked(node)"
        >
          <div
            class="icon-box"
            v-if="haveChild"
          >
            <svg
              @click="setExpanded(!isExpanded)"
              t="1709605135944"
              class="icon"
              viewBox="0 0 1024 1024"
              version="1.1"
              xmlns="http://www.w3.org/2000/svg"
              p-id="2647"
              width="25"
              height="25"
              :class="{rotate:isExpanded}"
              :fill="select_id==node.id?'#ccc':'#000'"
            >
              <path
                d="M500.8 604.779L267.307 371.392l-45.227 45.27 278.741 278.613L779.307 416.66l-45.248-45.248z"
                p-id="2648"
              ></path>
            </svg>
          </div>
          <span>{{node.name}}</span>
        </li>
        <ul v-if="haveChild&&isExpanded">
          <tree-node
            v-for="child in node.children"
            :key="child.id"
            :node="child"
            :select_id="select_id"
            @node-clicked="childClicked"
          ></tree-node>
        </ul>
      </div>
    </template>

    <script>
    export default {
      // 因为是递归组件 导入的时候一定要以懒加载的形式 不然会报错的
      components: { "tree-node": () => import("./TreeNode.vue") },
      data() {
        return {
          // 展开情况
          isExpanded: false,
        };
      },
      props: {
        node: Object,
        select_id: String | Number,
      },
      computed: {
        // 判断是否可以展开
        haveChild() {
          return this.node.children && this.node.children.length;
        },
      },
      methods: {
        // 设置展开情况
        setExpanded(value) {
          this.isExpanded = value;
        },
        // 点击情况
        childClicked(clickedNode) {
          this.$emit("node-clicked", clickedNode);
        },
      },
    };
    </script>

    <style scoped>
    .icon-box {
      width: 40px !important;
      display: flex;
    }
    .icon {
      text-align: center;
      margin-right: 10px;
      transition: all 0.2s ease;
      transform: rotate(-90deg);
    }
    ul {
      list-style: none;
      padding-left: 20px !important;
    }
    .flex {
      display: flex;
      align-items: center;
      padding: 10px 20px;
      user-select: none;
    }

    .flex a {
      padding: 0 !important;
    }

    .flex:hover {
      background: #f2f9f5 !important;
      color: #008737 !important;
    }

    .flex:hover a {
      background: #f2f9f5 !important;
      color: #008737 !important;
    }

    .a-selected,
    .a-selected a {
      background: #008737 !important;
      color: #fff !important;
    }
    .rotate {
      transform: rotate(0);
    }
    </style>

三、使用组件

    <!--
     * @Author: 羊驼
     * @Date: 2024-03-12 16:49:46
     * @LastEditors: 羊驼
     * @LastEditTime: 2024-03-12 17:21:17
     * @Description: 示例
    -->
    <template>
      <div>
        <tree
          :nodes="nodes"
          :select_id="select_id"
          @node-clicked="handleNodeClicked"
        >
        </tree>
      </div>
    </template>

    <script>
    import Tree from "./components/Tree.vue";
    export default {
      // 组件引入
      components: { Tree },
      data() {
        return {
          // 测试数据
          nodes: [
            {
              id: 1,
              name: "测试1",
              children: [
                {
                  id: 2,
                  name: "测试1-1",
                  children: [
                    {
                      id: 3,
                      name: "测试2-1",
                    },
                  ],
                },
                {
                  id: 4,
                  name: "测试1-2",
                },
              ],
            },
            {
              id: 6,
              name: "测试2",
            },
          ],
          select_id: "",
        };
      },
      methods: {
        // 节点选中后
        handleNodeClicked(node) {
          this.select_id = node.id;
        },
      },
    };
    </script>

    <style>
    </style>

四、效果展示

编辑

最简版本 后续有什么需求 可以自己定义插槽和事件实现

ES5中的继承实现

2025年4月1日 17:20

在ES5中,是没有extends可用的,想要继承,就要自己手动解决了。 在继承里面,要解决的问题,简单的来看就是,属性还有方法的继承。

1.属性的继承

可以使用 Parent.call(this, ...args) 来解决。
如果这里不使用 call,直接将Child的原型指向了Parent,这里会出现引用类型数据共享问题

// 这里如果有引用类型对象,会造成数据共享的问题
Child.prototype = new Parent();

2.方法的继承

子类继承父类的方法,简单点就可以直接将子类的原型指向父类的原型。

Child.prototype = Parent.prototype

但是这里还会存在一个问题,就是子类修改方法,会影响父类。所以,这里需要另外建造一个对象,从而不影响父类,也就是使用Object.create()

Child.prototype = Object.create(Parent.prototype)

3.原型链完整

保证instanceofconstructor正确

  • instanceof 应该能正确判断继承关系(child instanceof Parent 应为 true)。
  • child.constructor 应指向 Child 而不是 Parent
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child; // 修复 constructor

4.方法重写

子类方法覆盖父类方法,同时还能调用父类方法
在子类的原型上重写方法,然后在内部使用call来调用父类的方法,并写出子类自己的逻辑

Child.prototype.sayName = function() {
  Parent.prototype.sayName.call(this); // 调用父类方法
  console.log("Child's sayName");
};

5.静态方法继承

静态属性&方法
Parent.type = '0'
Parent.staticMethod = function(){}

例如这种该如何继承
在ES6上有Object.setPrototypeOf(Child, Parent)
在ES5上可以直接手动赋值

for (var key in Parent) {
  if (Parent.hasOwnProperty(key)) {
    Child[key] = Parent[key];
  }
}

以上面5点来进行总结,ES5中的继承可以写成如下的形式

function Parent(name) {
  this.name = name;
}

Parent.prototype.sayName = function() {
  console.log(this.name);
};

function Child(name, age) {
  Parent.call(this, name); // (1) 属性继承
  this.age = age;
}

Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child; // (3) 修复 constructor

// (5) 静态方法继承(可选)
for (var key in Parent) {
  if (Parent.hasOwnProperty(key)) {
    Child[key] = Parent[key];
  }
}

手把手教你在浏览器中处理流式传输(Event Stream/SSE)

作者 桜吹雪
2025年4月1日 17:15

前文:手把手教你在浏览器和RUST中处理流式传输 提到如何简单的处理流式输出,但是后来发现这个写法有bug,下面讲解一下更好的写法

顺便补充一下,上一篇文章提到的IterableReadableStream来自@langchain/core,你可以这样导入使用:

import { IterableReadableStream } from '@langchain/core/utils/stream'

处理Event Stream

除了上一章的ndjson以外,最常用就是Event Stream了,包括OpenAi等一众ai服务提供商都会提供sse接口,并且以Event Stream的格式进行输出,先来看看ai是怎么理解Event StreamSSE的:

Server-Sent Events (SSE) ,一种基于 HTTP 的轻量协议,允许服务器向客户端推送实时数据流。

SSE 格式规范

  • 数据通过 HTTP 流式传输,内容类型为 text/event-stream

  • 每条事件由字段组成,用换行符分隔。字段包括:

    • data: 事件的具体内容(必填)。
    • event: 自定义事件类型(可选)。
    • id: 事件唯一标识符(可选)。
    • retry: 重连时间(毫秒,可选)。

示例

event: status_update
data: {"user": "Alice", "status": "online"}

id: 12345
data: This is a message.

retry: 3000

那再来看看ai输出的结果:

image.png

很标准的text/event-stream格式

使用langchainjs处理

你以为我要像上一篇一样开始手搓处理代码了吗,no no no,我们还是使用langchainjs进行处理,原因后面会提到。

这里推荐一个fetch封装工具:ofetch,一个类似axios的库,作用大家应该都懂了吧,这里我拿火山的接口来演示:

// vite.config.js
export default defineConfig({
  base: "/",
  server: {
    proxy: {
      "/huoshan": {
        changeOrigin: true,
        ws: true,
        secure: false,
        target: "https://ark.cn-beijing.volces.com",
        rewrite: (path) => path.replace(/^\/huoshan/, ""),
      },
    },
  },
});

// vue.config.js
module.export = {
  devServer: {
    compress: false, // 重点!!!不关闭则有可能导致无法正常流式返回
    proxy: {
      '/huoshan': {
        target: 'https://ark.cn-beijing.volces.com', // 代理
        changeOrigin: true,
        ws: true,
        secure: false,
        pathRewrite: {
          '^/huoshan': '',
        },
      },
    }
  }
}

如果是webpack的话,一定要关闭devServercompress,不然会导致整个请求结束才返回,这样就不是流式输出了。

// request.js

import { ofetch } from "ofetch";

export const fetchRequest = ofetch.create({
  baseURL: '/huoshan',
  timeout: 60000,
  onRequest({ options }) {
    options.headers.set('Authorization', 'Bearer xxxxx') // 替换火山api的key
  },
})
import { fetchRequest } from "./request";
import { convertEventStreamToIterableReadableDataStream } from "@langchain/core/utils/event_source_parse";

async function test() {
  const res = await fetchRequest("/api/v3/chat/completions", {
    responseType: "stream",
    method: "post",
    body: {
      model: "deepseek-v3-250324",
      messages: [
        {
          role: "user",
          content: "你是谁?",
        },
      ],
      stream: true,
    },
  });
  const stream = convertEventStreamToIterableReadableDataStream(res);
  for await (const chunk of stream) {
    console.log(chunk);
  }
}
test()

image.png

返回正常,不过要注意,结尾有个[DONE],所以不能无脑反序列化,

for await (const chunk of stream) {
  if (chunk !== '[DONE]') {
    console.log(JSON.parse(chunk))
  }
}

这样就拿到每个chunk了,当然你可以将test方法改成生成器,然后for里面yield JSON.parse(chunk)

为什么要用langchainjs封装好的方法处理

既然大家都知道流式输出是一个一个chunk的方式返回,那么是不是有可能一行的文本,拆分成两个chunk(在js看来是ArrayBuffer)?而一个utf8字符是定长的,可能是1-3字节,那是不是有可能在某个字符的时候,其中一部分字节拆分到一个chunk,然后剩下部分字节拆分到下一个chunk?

这样就会导致你在decode的时候发生报错,无法正常decode成文字,所以langchainjs的方法考虑到这个情况:

image.png

代码在:github.com/langchain-a…

其他关注点

使用代理时需要注意

上面的webpack配置已经讲解了一下devServer应该怎么配置才能流式输出。还有就是使用nginx代理的时候也需要修改一下配置:

server {
listen 80;
location /huoshan/ {
                # http1.1才支持长连接
proxy_http_version 1.1;
# 关闭代理缓冲
proxy_buffering off;
# 设置代理缓冲区大小
proxy_buffer_size 10k;
# 设置代理缓冲区数量和大小
proxy_buffers 4 10k;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass https://ark.cn-beijing.volces.com/;
}


}

其实就是关闭一些代理缓冲,以及设置一下缓冲区,为什么要这样设置,这里有请懂nginx配置的大佬细说一下😜

微信小程序map组件渲染几百个marker后,页面卡顿,如何解决?

作者 wangpq
2025年4月1日 17:00

问题回溯

前些时候,我们的一码游小程序地图组件一下子渲染了几百个maker,加载特别慢,也特卡,如下图所示:

1743413008122.png

看着地图上maker一大堆,感觉就很扎心,让人头皮发麻。

产品让我优化一下,她建议一开始只加载50条。

解决方案

那如何保证既能加载maker不卡顿,又能保证我们需要的数据完整不丢失呢?

参考线上他人的想法,我和后端还有产品商量了下,最终解决方案如下:

控制地图可见区域大小,只获取地图当前区域范围内的maker,操作移动地图重新请求并加载maker。

看到这里的小伙伴可能会吐槽,怎么不弄成下图,类似百度那样的搜索效果呢?

1743475095474.png 百度地图这效果,是随着地图视野发生变化,视野范围较大,一个小区域范围maker太多,在一个maker加数字表示,maker不用都显示出来,视野范围较小,数据不多的时候,则显示maker。

其实我们也想这样做,但是这样的效果,目前后端的数据结构并不支持,主要开发时间也有限,不允许我们这样做,只能退而求其次,用上面提到的方案了。

具体步骤

从微信官方文档小程序( 地图map )查看API,我们可以发现,

  • 控制地图可见区域大小,可以使用 min-scale、max-scale、scale;
  • 操作(移动,缩放)地图,可以使用 bindregionchange 。

scale可以控制当前地图缩放级别,min-scale、max-scale分别控制了地图最小和最大缩放级别,bindregionchange 在视野发生变化时触发,regionchange事件返回值对象中正好包含了当前地图区域位置中心点的经纬度、以及东北角和西南角位置的经纬度信息,如下:

1743474388750.png

如此,后端配合修改数据接口,前端将当前地图中心位置经纬度、以及东北角、西南角位置的经纬度信息传递给后端接口,让后端只查询当前的数据,前端再控制好缩放级别,选择一个比较合适的min-scale值,这样就达到了我们最初的预想。

关键部分代码:

<div class="app-wrap">
    <div class="item" v-for="(item, i) in appList" :key="i" @click="appClick(i, item, true)">
        <image class="icon" :src="$imgBaseUrl + item.icon" lazy-load mode="aspectFill"></image>
        <div class="name">{{ item.name }}<div class="name-en">{{ $i18n_en(item.name) }}</div>
        </div>
    </div>
</div>
<map 
    id="map" 
    ref="map" 
    :markers="markers" 
    :polyline="polyline" 
    style="width: 100%; height: 100%;"
    :latitude="latitude" 
    :longitude="longitude" 
    :scale="scale" 
    :min-scale="minScale"
    :max-scale="maxScale" 
    :show-location="showLocation" 
    @markertap="markertap" 
    @labeltap="markertap"
    @regionchange="onMapRegionchange"
>
    <cover-view slot="callout">
            <cover-view class="c-callout" v-for="(item, i) in markers" :marker-id="i" :key="i">
                    {{ i + 1 }}
            </cover-view>
    </cover-view>
</map>
appList: [
    {
            name: '景区景点',
            icon: `/tour/icon-spot.png`,
            value: 'scenic',
            scale: 15,
            noImgUrl: process.env.VUE_APP_IMG_URL + '/playToolsPKG/pic-05.png',
            getListFunc: () => this.footerClick('景区景点'),
            goDetailFunc: this.goScenicDetail
    },
    {
            name: '酒店民宿',
            icon: `/tour/icon-hotel.png`,
            value: '',
            maxScale: maxScaleValue,
            minScale: 17,
            scale: 18,
            latitude: 37.211836,
            longitude: 112.180908,
            noImgUrl: process.env.VUE_APP_IMG_URL + '/playToolsPKG/pic-09.png',
            getListFunc: this.getHotelList,
            goDetailFunc: this.goHotelDetail
    },
    {
            name: '餐饮',
            icon: `/tour/icon-food.png`,
            value: '',
            maxScale: maxScaleValue,
            minScale: 17,
            scale: 18,
            latitude: 37.203548,
            longitude: 112.185848,
            noImgUrl: process.env.VUE_APP_IMG_URL + '/playToolsPKG/pic-09.png',
            getListFunc: this.getFoodList,
            goDetailFunc: this.goFoodDetail
    },
]


// 顶部app
appClick(index, item) {
        this.popupTitle = item.name
        this.scrollTop = 0;
        this.popupList = [];
        this.markers = [];
        this.polyline = [];
        this.currentItemName = item.name
        this.pageNo = 1

        this.noImgUrl = item.noImgUrl

        this.appItem = item

        this.minScale = item.minScale ?? minScaleValue
        this.maxScale = item.maxScale ?? maxScaleValue
        this.nePoint = undefined
        this.swPoint = undefined

        this.showFloatMenu = false

        item.getListFunc()
},
onMapRegionchange(evt) {
        if (evt.type != 'end') {
                return
        }
        // console.log('---onMapRegionchange---end---', evt)
        const { centerLocation, region } = evt.detail
        this.currentLatitude = centerLocation.latitude
        this.currentLongitude = centerLocation.longitude
        this.nePoint = region.northeast
        this.swPoint = region.southwest
        if (this.nePoint && this.swPoint) {
                if (["酒店民宿", "餐饮"].includes(this.appItem.name)) {
                        this.appItem.getListFunc && this.appItem.getListFunc()
                }
        }
},

在regionchange事件中,开始和结束状态都会触发,如下方官方文档所述:

1743478379434.png 在目前的场景中,我们并不关心开始状态,因此,我在代码中阻断了开始状态

if (evt.type != 'end') {
        return
}

其他问题

其实到这里,针对渲染多个maker卡顿的问题,我们基本算是解决了。 可作为一个负责任的开发者,我们不能只为完成任务,不管质量。

上方的解决方案,开发人员还能明显发现存在下述问题:

在小程序顶部点击类型事件中,特别是地图的Regionchange事件,会频繁多次触发,导致后端接口多次请求。

1743477316121.png

1743477358887.png

如何解决 Regionchange 事件多次触发导致后端接口多次请求呢?

我们可以采用防抖处理,uniapp中已经提供此方法 uni.$u.debounce

onMapRegionchange(evt) {
if (evt.type != 'end') {
return
}
const func = () => {
console.log('---onMapRegionchange---end---', evt)
const { centerLocation, region } = evt.detail
this.currentLatitude = centerLocation.latitude
this.currentLongitude = centerLocation.longitude
this.nePoint = region.northeast
this.swPoint = region.southwest
if (this.nePoint && this.swPoint) {
if (["酒店民宿", "餐饮"].includes(this.appItem.name)) {
this.appItem.getListFunc && this.appItem.getListFunc()
}
}
}
uni.$u.debounce(func, 360)
},

1743477932408.png

debounce函数中的时间我设置为360毫秒,稍微有点大,太小了,我发现控制不住,更大一些,等待时间过长,感觉体验又不好。

到这里,其实 getListFunc 还是会执行两次,一次是在上方点击事件(appClick )执行时,另一次则在Regionchange事件中了,我觉得还可以继续优化。当然下面讲到的优化过程已经和我们这次的主题无关了,不想看的请直接忽略。

回到接口请求两次的问题,我发现第一次请求后端接口的时候,当时并没有获取到当前地图的中心位置和东北角、西南角位置信息,所以我在调用后端接口的方法前,增加了以下代码

if(!this.nePoint){
    return 
}

由于这个页面的特殊设计,每次在上方重新点击类型切换后,需要重新定位地图位置,而原来的同事(开发者)在请求后端接口获取数据后,才调用了地图定位方法,导致如果不调用一次后端接口,就无法进行定位,恰巧这样的后端请求接口数据根本就不是我现在所需要的(没有地图中心点位置和东北西南角位置数据来限定数据,请求后端返回的数据可能很多,导致卡顿),就譬如下方酒店民宿的请求方法:

getHotelList() {
        this.markers = []
        this.scale = this.appItem.scale
        this.$http.post("/hotel/pageList", {
                status: 1,
                pageNo: this.pageNo,
                pageSize: this.nePoint ? 99 : 50,
                latitude: this.latitude,
                longitude: this.longitude,
                currentLatitude: this.currentLatitude,
                currentLongitude: this.currentLongitude,
                nePoint: this.nePoint,
                swPoint: this.swPoint,
        }).then(({ data }) => {
                this.markers = data.records.map((item, index) => {
                        let gId = this.getId()
                        return {
                                // 保留自定义参数, 用于查询或其他操作
                                ...item,
                                iconPath: item.subMchId ? `${this.$imgBaseUrl}/tour/icon-marker-hotel-bind.png` : `${this.$imgBaseUrl}/tour/icon-marker-hotel.png`,
                                id: gId, //保证有id, 点击时才能显示tip
                                uid: gId,
                                dataId: item.hotelId,
                                latitude: item.latitude,
                                longitude: item.longitude,
                                width: item.subMchId ? 28 : 25,
                                height: 28,
                                label: {
                                        content: item.hotelName,
                                        borderRadius: 36,
                                        borderWidth: 1,
                                        borderColor: item.subMchId ? '#c69553' : '#999',
                                        bgColor: item.subMchId ? '#fefefc' : '#fff',
                                        color: item.subMchId ? '#c59350' : '#363636',
                                        display: 'ALWAYS',
                                        textAlign: 'center',
                                        padding: 5,
                                },
                                callout: {
                                        content: item.hotelName,
                                        padding: 5,
                                        display: 'BYCLICK'
                                }
                        }
                })

                this.remakeList(this.markers, {
                        title: 'hotelName',
                        address: 'address',
                        coverUrl: 'coverPath',
                        phone: 'telephone',
                        imgsUrl: 'detailPath'
                }, data.total)

                this.scale = this.appItem.scale
                this.goToCenter({
                        latitude: this.appItem.latitude,
                        longitude: this.appItem.longitude
                })
        })
},

基于上面提到的问题,我将此方法修改如下:

getHotelList() {
        this.goToCenter({
                latitude: this.appItem.latitude,
                longitude: this.appItem.longitude
        })

        if(!this.nePoint){
                return 
        }

        this.markers = []
        this.$http.post("/hotel/pageList", {
                status: 1,
                pageNo: this.pageNo,
                pageSize: this.nePoint ? 99 : 50,
                latitude: this.latitude,
                longitude: this.longitude,
                currentLatitude: this.currentLatitude,
                currentLongitude: this.currentLongitude,
                nePoint: this.nePoint,
                swPoint: this.swPoint,
        }).then(({ data }) => {
                this.markers = data.records.map((item, index) => {
                        let gId = this.getId()
                        return {
                                // 保留自定义参数, 用于查询或其他操作
                                ...item,
                                iconPath: item.subMchId ? `${this.$imgBaseUrl}/tour/icon-marker-hotel-bind.png` : `${this.$imgBaseUrl}/tour/icon-marker-hotel.png`,
                                id: gId, //保证有id, 点击时才能显示tip
                                uid: gId,
                                dataId: item.hotelId,
                                latitude: item.latitude,
                                longitude: item.longitude,
                                width: item.subMchId ? 28 : 25,
                                height: 28,
                                label: {
                                        content: item.hotelName,
                                        borderRadius: 36,
                                        borderWidth: 1,
                                        borderColor: item.subMchId ? '#c69553' : '#999',
                                        bgColor: item.subMchId ? '#fefefc' : '#fff',
                                        color: item.subMchId ? '#c59350' : '#363636',
                                        display: 'ALWAYS',
                                        textAlign: 'center',
                                        padding: 5,
                                },
                                callout: {
                                        content: item.hotelName,
                                        padding: 5,
                                        display: 'BYCLICK'
                                }
                        }
                })

                this.remakeList(this.markers, {
                        title: 'hotelName',
                        address: 'address',
                        coverUrl: 'coverPath',
                        phone: 'telephone',
                        imgsUrl: 'detailPath'
                }, data.total)
        })
},

使用!this.nePoint在后端接口请求前加了阻断,并且将地图定位方法goToCenter提到最前面,执行后端接口请求前先地图定位。

如此,每次在顶部切换类型请求数据的时候(上面提到的事件方法appClick),就能保证只执行1次,又能正确获取到地图视野范围内的数据了。

代码修改前后对比图: 1743494353942.png

其他类型,比如餐饮,改法跟酒店民宿相同。就不一一介绍了。

在编辑本文档的时候,结果无意中又发现了一个问题,在顶部点击切换类型的过程中(事件方法见开头的方法appClick),点击当前的类型,maker会消失。 可以加一个逻辑,判断是否当前,是不再执行下方的逻辑。修改如下:

<div class="app-wrap">
        <div class="item" v-for="(item, i) in appList" :key="i" @click="appClick(i, item,true)">
                <image class="icon" :src="$imgBaseUrl + item.icon" lazy-load mode="aspectFill"></image>
                <div class="name">{{ item.name }}<div class="name-en">{{ $i18n_en(item.name) }}</div>
                </div>
        </div>
</div>
// 顶部app
appClick(index, item, isTop=false) {
        if(this.currentIndex === index && isTop){
                return
        }
        isTop ? this.currentIndex=index : undefined;
        this.popupTitle = item.name
        this.scrollTop = 0;
        this.popupList = [];
        this.markers = [];
        this.polyline = [];
        this.currentItemName = item.name
        this.pageNo = 1

        this.noImgUrl = item.noImgUrl

        this.appItem = item

        this.minScale = item.minScale ?? minScaleValue
        this.maxScale = item.maxScale ?? maxScaleValue
        this.scale = item.scale?? 14
        this.nePoint = undefined
        this.swPoint = undefined

        this.showFloatMenu = false

        item.getListFunc()
},

终于,一切OK了。

echarts地图轮播markpoint-自用记录📝

作者 南茗啊
2025年4月1日 16:21

echarts地图轮播markpoint

正常添加echarts地图后,添加markpoint图层

 //引入echarts 注册地图 略过 只展示关键代码
option.series= [
            {
              // roam: true,
              name: '地图',
              type: 'map',
              id: 'mapData',
              map: 'china',
              data: this.outdata,  //无需在意 配合visualMap给地图区块添加颜色使用
              center: [95, 35],
              zoom: 1.5,
              z: 2,
              itemStyle: {
                borderColor: 'rgba(0,0,0,0.15)',
                borderWidth: 1.3,
                shadowColor: 'rgba(0,0,0,0.2)',
              },
              emphasis: {
                label: {
                  show: true,
                },
              },
            },
  //下方是markpoint代码 因为echarts官方已经不推荐使用markpoint 改为使用scatter
            {
              type: 'scatter',
              coordinateSystem: 'geo',
              //下方的symbol替换为需要展示的图标的base64编码即可
              symbol:
                'image://data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAYAAACpSkzOAAAAAXNSR0IArs4c6QAAA4VJREFUSEvNlV1oHUUUx/9nZnbv7s3dG2+akGoNGgp5UtDaEmqxNoIfXIUSYksrERP8Kj6KghSFvFQRTHzyIS0aWqQiIUUhHz4IxlDbBxUEK0UQCrYi9MamuTH3Y2d3jsy9DQZpbjcpBAeGhd2Z85v/mf85S9ikQZvEwf8P9ORXiy3kpnZp42yPockT8rcgLH0//lTztSRZuaWiA9NLbQtIH12uYlAzNRsGSAFCAY7Li55vxlKm8u7M3qDQCNgQ9NjE8o5q1Z+qaGytBZH1ycQgSYBCbfo+/sz6pfz0nsxPa8HWBO3/rNRxteydq4Z8tw1KBLC4Aas9GSxRB0qGH+DKFkc8/OWjdPlmsDVB+z7WZ/5eEr0kAbNyemK0NHFN1UKVwPa9hQoGK0ImMGfmnlB9iUH5U+HO60X1XVhhVyqCIaD9Do2hxwn3tdnIwC/zBkPnCH+UJOxhLFCkEbYF0Z7pHveH/8JuqqhnVB9dKsljiOp3oUSMsecMulodkM3hjfHrXxoDkwKhIJAgIAUEvn579pnUsUSgncPhaROpw2AAbLBjWwUn+n3ULmp16THj1UmNHwtO3SgKaAri8bn9zsFEoIc+NJMI8XQtZzrC3s4ihvtzIAtepYjBeHOqim9+dwFVd2GQNTPfPqvyiUC7hsPjcei8TMaAqxo5M4/Jd7Yi5cjVHFS1Qe/xCq6yB0gCKyCdNaNnD6kjiUDdH0TPx5E8aTQTRQZhoYBD3RHeOHwXXKduhlAbjEwtYPxCBm7WBQuCccDNW/TA1wfcU4lAu0cWW8Jy5gI03Qkw4uIywsI8HrhH45EHg1r6zl4M8fO1AE5rFtITsF4gj6+0txfv/6I3dz0RyC7qfi98TVfUR/buhTGIl8vQxSXEVQ0oCbc5A5FrAnkSwlpbMrxAH5l7yRtNXEd24b4hVmUnPhGxGEAEMDNgDGDqHUG4slakJGwJCPgZPSa7nFdmeyhaF6gOu+SV3Y4JE8u8sQBrOtt2VtqRdbuts3Q01dKp+mbyVF13r1vZcO/QJa9dbfskMvW6YgNQre3U+55sMqfT2csvzg52VjbcvVc2WmUVp+NkFMuDbJVZmENwgvDzXJf7QiMlKzFu+T/6F8Ze2eFPo4j6bOqUH014rap/dpAaKlk3yG7YPcJ+XNHvixQxkXrr/OtUbpSu1d8SK0oacMNmuF3AhlJ3O9BNS90/oxFMKoVN5+kAAAAASUVORK5CYII=',
              symbolSize: 30,
              symbolOffset: [0, '-50%'],
              label: {
                show: true,
                position: 'top',
                color: '#333333',
                fontSize: 14,
                fontWeight: 600,
                formatter: '{b}', //配合下方data中的name使用 展示name
                textBorderColor: '#ffffff',
                textBorderWidth: 2,
                textBorderType: 'solid',
              },
              emphasis: {
                scale: 1.2,
                label: {
                  fontSize: 18,
                },
              },
              select: {
                label: {
                  color: '#333333',
                  fontSize: 18,
                  fontWeight: 600,
                },
              },
              data: [
                {
                  name: '济南片区',
                  value: [117.2, 36.7],
                },
                {
                  name: '西南片区',
                  value: [104.065701, 30.659487],
                },
                {
                  name: '青岛片区',
                  value: [120.1, 36.4],
                },
                {
                  name: '华中片区',
                  value: [117.291321, 31.861125],
                },
                {
                  name: '华北片区',
                  value: [116.4, 39.9],
                },
                {
                  name: '华东片区',
                  value: [121.473701, 31.230393],
                },
                {
                  name: '华南片区',
                  value: [113.6, 23.1],
                },
                {
                  name: '东南片区',
                  value: [119.4543, 26.2875],
                },
                {
                  name: '中原片区',
                  value: [113.653427, 34.761125],
                },
              ],
            },
          ],

轮播代码如下:

思路是对当前高亮节点进行取消高亮操作,然后在高亮下一个节点

需要注意seriesIndex 可以通过在鼠标移入事件中添加打印信息获取

//该段代码在echarts初始化之后调用即可
startLunbo () {
        if (this.intervalId) {
          clearInterval(this.intervalId)
          this.intervalId = null
        }
        this.intervalId = setInterval(() => {
          this.myMapChart.dispatchAction({
            type: 'downplay',
            seriesIndex: 1,
            dataIndexInside: this.lunboId === 0 ? 8 : this.lunboId - 1, //和上面的代码片段中data的长度有关
          })
          this.myMapChart.dispatchAction({
            type: 'highlight',
            seriesIndex: 1,
            dataIndexInside: this.lunboId,
          })
          this.toolIdx = this.lunboId
          this.lunboId++
          if (this.lunboId > 8) { //和上面的代码片段中data的长度有关
            this.lunboId = 0
          }
        }, 2500)
      }

其中鼠标的移入移出事件 自行完成即可

❌
❌