普通视图

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

别再死磕框架了!你的技术路线图该更新了

2025年11月18日 16:03

先说结论:

前端不会凉,但“只会几个框架 API”的前端,确实越来越难混
这两年“前端要凉了”“全栈替代前端”的声音此起彼伏,本质是门槛重新洗牌:

  • 简单 CRUD、纯样式开发被低代码、模板代码和 AI 模型快速蚕食;
  • 复杂业务、工程体系、跨端体验、AI 能力集成,反而需要更强的前端工程师去撑住。

如果你对“前端的尽头是跑路转管理”已经开始迷茫,那这篇就是给你看的:别再死磕框架版本号,该更新的是你的技术路线图。


一、先搞清楚:2025 的前端到底在变什么?

框架红海:从“会用”到“用得值”

React、Vue、Svelte、Solid、Qwik、Next、Nuxt……Meta Framework 一大堆,远远超过岗位需求。
现在企业选型更关注:

  • 生态成熟度(如 Next.js 的 SSR/SSG 能力)
  • 框架在应用生命周期中的角色(渲染策略、数据流转、SEO、部署)

趋势:

  • 框架 Meta 化(Next.js、Nuxt)将路由、数据获取、缓存策略整体纳入规范;
  • 约定优于配置,不再是“一个前端库”,而是“一套完整解决方案”。

以前是“你会 Vue/React 就能干活”,现在是“你要理解框架在整个应用中的角色”。


工具有 AI,开发方式也在变

AI 工具(如 Cursor、GitHub Copilot X)可以显著提速,甚至替代重复劳动。
真正拉开差距的变成了:

  • 你能给 AI 写出清晰、可实现的需求描述(Prompt);
  • 你能判断 AI 生成代码的质量、潜在风险、性能问题;
  • 你能基于生成结果做出合理抽象和重构。

AI 不是来抢饭碗,而是逼你从“码农”进化成“架构和决策的人”。


业务侧:前端不再是“画界面”,而是“做体验 + 做增长”

  • B 端产品:交互工程师 + 低代码拼装师 + 复杂表单处理专家;
  • C 端产品:与产品运营深度捆绑,懂 A/B 测试、埋点、Funnel 分析、广告投放链路;
  • 跨平台:Web + 小程序 + App(RN/Flutter/WebView)混合形态成为常态。

那些还在喊“切图仔优化 padding”的岗位确实在消失,但对“懂业务、有数据意识、能搭全链路体验”的前端需求更高。


二、别再死磕框架 API:2025 的前端核心能力长什么样?

基石能力:Web 原生三件套,得真的吃透

重点不是“会用”,而是理解底层原理:

  • JS:事件循环、原型链、Promise 执行模型、ESM 模块化;
  • 浏览器:渲染流程(DOM/CSSOM/布局/绘制/合成)、HTTP/2/3、安全防护(XSS/CSRF)。

这块扎实了,你在任何框架下都不会慌,也更能看懂“框架为什么这么设计”。


工程能力:从“会用脚手架”到“能看懂和调整工程栈”

Vite、Rspack、Turbopack 等工具让工程构建从“黑魔法”变成“可组合拼装件”。
你需要:

  • 看懂项目的构建配置(Vite/Webpack/Rspack 任意一种);
  • 理解打包拆分、动态加载、CI/CD 流程;
  • 能排查构建问题(路径解析、依赖冲突)。

如果你在团队里能主动做这些事,别人对你的“级别判断”会明显不一样。


跨端和运行时:不只会“写 Web 页”

2025 年前端视角的关键方向:

  • 小程序/多端框架(Taro、Uni-app);
  • 混合方案(RN/Flutter/WebView 通信机制);
  • 桌面端(Electron、Tauri)。

建议:

  • 至少深耕一个“跨端主战场”(如 Web + 小程序 或 Web + Flutter)。

数据和状态:从“会用 Vuex/Redux”到“能设计状态模型”

现代前端复杂度 70% 在“数据和状态管理”。
进阶点在于:

  • 设计合理的数据模型(本地 UI 状态 vs 服务端真相);
  • 学会用 Query 库、State Machine 解耦状态与视图。

当你能把“状态设计清楚”,你在复杂业务团队里会非常吃香。


性能、稳定性、可观测性:高级前端的硬指标

你需要系统性回答问题,而不是“瞎猜”:

  • 性能优化:首屏加载(资源拆分、CDN)、运行时优化(减少重排、虚拟列表);
  • 稳定性:错误采集、日志上报、灰度发布;
  • 工具:Lighthouse、Web Vitals、Session Replay。

这块做得好的人往往是技术骨干,且很难被低代码或 AI 直接替代。


AI 时代的前端:不是“写 AI”,而是“让 AI 真正跑进产品”

你需要驾驭:

  • 基础能力:调用 AI 平台 API(流式返回处理、增量渲染);
  • 产品思维:哪些场景适合 AI(智能搜索、文档问答);如何做权限控制、错误兜底。

三、路线图别再按“框架学习顺序”排了,按角色来选

初中级:从“会用”到“能独立负责一个功能”

目标:

  • 独立完成中等复杂度模块(登录、权限、表单、列表分页)。

建议路线:

  • 夯实 JS + 浏览器基础;
  • 选择 React/Vue + Next/Nuxt 做完整项目;
  • 搭建 eslint + prettier + git hooks 的开发习惯。

进阶:从“功能前端”到“工程前端 + 业务前端”

目标:

  • 优化项目、推进基础设施、给后端/产品提技术方案。

建议路线:

  • 深入构建工具(Webpack/Vite);
  • 主导一次性能优化或埋点方案;
  • 引入 AI 能力(如智能搜索、工单回复建议)。

高级/资深:从“高级前端”到“前端技术负责人”

目标:

  • 设计技术体系、推动长期价值。

建议路线:

  • 明确团队技术栈(框架、状态管理、打包策略);
  • 主导跨部门项目、建立知识分享机制;
  • 评估 AI/低代码/新框架的引入价值。

四、2025 年不要再犯的几个错误

  1. 只跟着热点学框架,不做项目和抽象

    • 选一个主战场 + 一个备胎(React+Next.js,Vue+Nuxt.js),用它们做 2~3 个完整项目。
  2. 完全忽略业务,沉迷写“优雅代码”

    • 把重构和业务迭代绑一起,而不是搞“纯技术重构”。
  3. 对 AI 持敌视和逃避态度

    • 把重复劳动交给 AI,把时间投到架构设计、业务抽象上。
  4. 把“管理”当成唯一出路

    • 做前端架构、性能优化平台、低代码平台的技术专家,薪资和自由度不输管理岗。

五、一个现实点的建议:给自己的 2025 做个“年度规划”

Q1:

  • 选定主技术栈(React+Next 或 Vue+Nuxt);
  • 做一个完整小项目(登录、权限、列表/详情、SSR、部署)。

Q2:

  • 深入工程化方向(优化打包体积、搭建监控埋点系统)。

Q3:

  • 选一个业务场景引入 AI 或配置化能力(如智能搜索、低代码表单)。

Q4:

  • 输出和沉淀(写 3~5 篇技术文章、踩坑复盘)。

最后:别问前端凉没凉,先问问自己“是不是还停在 2018 年的玩法”

  • 如果你还把“熟练掌握 Vue/React”当成简历亮点,那确实会焦虑;
  • 但如果你能说清楚:
    • 在复杂项目里主导过哪些工程优化;
    • 如何把业务抽象成可复用的组件/平台;
    • 如何在产品里融入 AI/多端/数据驱动;
      那么,在 2025 年的前端市场,你不仅不会“凉”,反而会成为别人眼中的“稀缺”。

别再死磕框架了,更新你的技术路线图,从“写页面的人”变成“打造体验和平台的人”。这才是 2025 年前端真正的进化方向。

Ract Router v7:最全基础与高级用法指南(可直接上手)

作者 丁点阳光
2025年11月18日 15:43

React Router v7 已经正式成为现代 React 应用的默认路由方案。相比过去的版本,v7 在数据加载、路由懒加载、错误边界、路由模块化等方面做了更统一、更现代化的设计。

本文带你快速掌握 基础用法 + 高级用法,适合从 v5/v6 升级或新项目使用

一、核心理念与变化概览

React Router v7 主要围绕三个关键词:

1. 路由是 UI 的一部分

-router 是组件

  • 不再是配置式为主,组件式更自然
  • Layout + Outlet 成为主流

2. 数据路由(Data Router)统一化

提供统一 API:

  • loader 数据加载
  • action 数据提交
  • lazy 懒加载路由资源
  • errorElement 错误边界

3. 组件即路由

v7 保留了 v6 的 createBrowserRouterRouterProvider,并继续强化嵌套路由的概念

二、基础用法

1. 安装

npm install react-router-dom

2. 最简路由结构

import { createBrowserRouter, RouterProvider } from "react-router-dom";

const router = createBrowserRouter([
  {
    path: "/",
    element: <HomePage />,
  },
  {
    path: "/about",
    element: <AboutPage />,
  },
]);

export default function App() {
  return <RouterProvider router={router} />;
}

3. Layout + Outlet(嵌套路由)

Layout 是 v7 强调的最佳实践:

import { Outlet, Link } from "react-router-dom";

function Layout() {
  return (
    <div>
      <nav>
        <Link to="/">首页</Link>
        <Link to="/profile">个人中心</Link>
      </nav>

      <main>
        <Outlet /> {/* 子路由在这里渲染 */}
      </main>
    </div>
  );
}

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    children: [
      { index: true, element: <HomePage /> },
      { path: "profile", element: <ProfilePage /> },
    ],
  },
]);

三、懒加载(async lazy)——强烈推荐写法

React Router v7 官方推荐使用 async lazy() 来进行路由级按需加载。

🔥 推荐写法:async lazy()

const router = createBrowserRouter([
  {
    path: "/",
    async lazy() {
      const Component = (await import("./pages/Layout")).default;
      return { Component };
    },
    children: [
      {
        index: true,
        async lazy() {
          const Component = (await import("./pages/Home")).default;
          return { Component };
        },
      },
      {
        path: "profile",
        async lazy() {
          const Component = (await import("./pages/Profile")).default;
          return { Component };
        },
      },
    ],
  },
]);

优势:
✔ 语法干净
✔ 和 Data Router 完整兼容
✔ 自动 Suspense
✔ 支持 SSR(未来版本)
✔ 大型项目结构清晰

四、跳转与参数读取

1. 跳转

const navigate = useNavigate();
navigate("/profile");

2. 获取路径参数

const { id } = useParams();

3. 获取查询参数

const [searchParams] = useSearchParams();
const keyword = searchParams.get("keyword");

五、跳转与参数读取(加强版)

这一部分很多人用得不全,这里讲 跳转方式、路径参数、查询参数、状态传参 4 大类。


📌 1. 跳转导航

(1)useNavigate:最常用跳转

import { useNavigate } from "react-router-dom";

const nav = useNavigate();

nav("/profile");                 // 普通跳转
nav(-1);                         // 返回上一页
nav("/login", { replace: true }); // 不留历史记录

(2)链接跳转 Link

import { Link } from "react-router-dom";

<Link to="/about">关于我们</Link>

(3)按钮跳转 Navigate 组件

适合条件跳转:

{ isLogin ? <Dashboard /> : <Navigate to="/login" replace /> }

📌 2. 路径参数(URL Params)

例如访问:
/user/123

定义路由:

{
  path: "user/:id",
  element: <UserDetail />
}

读取参数:

import { useParams } from "react-router-dom";

const { id } = useParams(); // id === "123"

注意:

  • params 一定是字符串类型
  • 可用 Zod/Number() 做转换

📌 3. 查询参数(Search Params)

例如访问:
/list?page=2&keyword=test

读取:

import { useSearchParams } from "react-router-dom";

const [search] = useSearchParams();

const page = search.get("page"); 
const keyword = search.get("keyword");

修改:

const [search, setSearch] = useSearchParams();

setSearch({ page: 3 });

支持 append:

search.append("type", "A");
setSearch(search);

📌 4. 跳转时携带 state(非 URL)

类似 history.push 的 state:

nav("/detail", {
  state: { from: "list", id: 123 }
});

读取:

import { useLocation } from "react-router-dom";

const { state } = useLocation();
// state.from === "list"

用法:

  • 搜索列表 → 详情
  • 跨页面临时数据
  • 不污染 URL
  • 仅会话内有效

六、Data Router:loader 与 action(高级)

📌 为什么需要 Data Router?

传统 SPA 数据加载流程:

渲染组件 → useEffect → fetch 数据 → setState →再渲染

但这有几个问题:

  • 多层嵌套时 useEffect 非常乱
  • SSR、预加载、切换页面时不能保证一致性
  • 异步错误很难集中处理
  • 首屏渲染过慢
  • React 渲染后才请求数据,白屏时间更长

React Router v7 引入 Data Router 后,流程变成:

进入某个路由 → 执行 loader → 数据准备好 → 再渲染组件

好处:

  • 页面渲染 之前 就拿到数据(首屏更快)
  • 数据逻辑从 UI 分离
  • 错误自动走路由 errorElement
  • 多个 loader 并行执行
  • 支持自动 revalidate(自动刷新)
  • SSR & CSR 统一开发体验

📌 1. loader:加载页面数据

loader 是路由级数据加载函数:

{
  path: "/detail/:id",
  loader: async ({ params, request }) => {
    const res = await fetch(`/api/detail/${params.id}`);
    return res.json();
  },
  element: <DetailPage />
}

组件内读取:

import { useLoaderData } from "react-router-dom";

const data = useLoaderData();

2. action:表单提交逻辑

{
  path: "/create",
  action: async ({ request }) => {
    const form = await request.formData();
    return createItem(form);
  },
  element: <CreatePage />
}

配合 <Form>

<Form method="post">
  <input name="title" />
  <button>提交</button>
</Form>

七、错误边界 errorElement

v7 支持路由级错误 UI:

{
  path: "/",
  element: <Layout />,
  errorElement: <ErrorPage />,
}

读取异常:

const err = useRouteError();

八、handle:路由元信息(meta)

可用于:

  • 页面标题
  • 面包屑
  • 权限
  • 路由描述
{
  path: "settings",
  handle: { title: "系统设置", auth: true },
  element: <SettingsPage />
}

读取所有匹配路由的 handle:

import { useMatches } from "react-router-dom";

const matches = useMatches();

九、模块化路由(大型项目最佳实践)

假设有三个模块:Dashboard、User、Project

1️⃣ Dashboard 模块

// routes/dashboard.routes.ts
export const dashboardRoutes = [
  {
    path: "dashboard",
    async lazy() {
      const Component = (await import("@/pages/Dashboard")).default;
      return { Component };
    },
    handle: { title: "Dashboard" },
  },
];

2️⃣ User 模块(带 loader/action)

// routes/user.routes.ts
export const userRoutes = [
  {
    path: "user",
    async lazy() {
      const Layout = (await import("@/pages/User/Layout")).default;
      return { Component: Layout };
    },
    children: [
      {
        index: true,
        async lazy() {
          const Component = (await import("@/pages/User/List")).default;
          return { Component };
        },
        handle: { title: "用户列表" },
      },
    ],
  },
];

3️⃣ Project 模块

// routes/project.routes.ts
export const projectRoutes = [
  {
    path: "project",
    async lazy() {
      const Layout = (await import("@/pages/Project/Layout")).default;
      return { Component: Layout };
    },
    children: [
      {
        index: true,
        async lazy() {
          const Component = (await import("@/pages/Project/List")).default;
          return { Component };
        },
        handle: { title: "项目列表" },
      },
     
    ],
  },
];

4️⃣ 主路由统一整合

// routes/index.ts
import { createBrowserRouter } from "react-router-dom";
import { dashboardRoutes } from "./dashboard.routes";
import { userRoutes } from "./user.routes";
import { projectRoutes } from "./project.routes";

const router = createBrowserRouter([
  {
    path: "/",
    async lazy() {
      const Layout = (await import("@/pages/Layout")).default;
      return { Component: Layout };
    },
    children: [
      ...dashboardRoutes,
      ...userRoutes,
      ...projectRoutes,
    ],
  },
  {
    path: "/login",
    async lazy() {
      const Component = (await import("@/pages/Login")).default;
      return { Component };
    },
  },
  {
    path: "*",
    async lazy() {
      const Component = (await import("@/pages/NotFound")).default;
      return { Component };
    },
  },
]);

export default router;

十、权限控制(结合 loader + handle)

{
  path: "admin",
  handle: { auth: true },
  loader: async () => {
    const login = await checkLogin();
    if (!login) throw redirect("/login");
  },
  element: <Admin />
}

满足所有权限场景。


结语

React Router v7 是目前 React 生态最全面的 SPA/SSR 路由方案,它的优势包括:

  • 统一的页面加载方式
  • 按需加载(lazy)
  • 模块化路由
  • 真正意义的路由数据层
  • 强大的错误边界和 handle

无论是小项目还是大型管理后台,它都足够可控且可扩展。

vue3+Cesium开发教程(14)---Cesium加载与删除geojson、kml、glb、3dtiles、czml数据

作者 GisCoder
2025年11月18日 15:16

本学习系列将以Cesium + Vue3 + Vite +Typescript+elementplus作为主要技术栈展开,后续会循序渐进,持续探索Cesium的高级功能,相关源码全部免费公开。详情请查看原文Cesium+Vue3学习系列(14)---Cesium加载与删除geojson、kml、glb、3dtiles、czml数据

1、geojson数据配置与加载方式


//geojson测试数据
export const geojsonTestInfo:ILayerItem = {
    id: LayerIdFlag.GEOMSON_TEST,
    name: "geojsonTest",
    type: "geojson",
    url: "/testdata/line.geojson",
    layer: "geojsonTest"
}

case 'geojson':
                handle = await Cesium.GeoJsonDataSource.load(item.url!, {
                    clampToGround: true,
                });
                (handle as Cesium.GeoJsonDataSource).show = show;
                this.viewer.dataSources.add(handle)
                //视角
                this.viewer.zoomTo(handle)
                break

2、kml数据配置与加载方式


//kml测试数据
export const kmlTestInfo:ILayerItem = {
    id: LayerIdFlag.KML_TEST,
    name: "kmlTest",
    type: "kml",
    url: "/testdata/gdpPerCapita2008.kmz",
    layer: "kmlTest"
}

            case 'kml':
                handle = await Cesium.KmlDataSource.load(item.url!, {
                    clampToGround: true,
                });
                (handle as Cesium.KmlDataSource).show = show
                this.viewer.dataSources.add(handle)
                this.viewer.zoomTo(handle)
                break

3、glb数据配置与加载方式


//glb测试数据
export const glbTestInfo:ILayerItem = {
    id: LayerIdFlag.GLB_TEST,
    name: "glbTest",
    type: "glb",
    url: "/testdata/CesiumMilkTruck.glb",
    layer: "glbTest",
    scale: 5,
    lon: 114.314521,
    lat: 22.543062,
    height: 0,
    heading:45,
    pitch: 0,
    roll: 0   
}

            case 'glb':
                let position = Cesium.Cartesian3.fromDegrees(item.lon, item.lat, item.height || 0);
                // 设置模型方向
                let hpRoll = new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(item.heading || 45), Cesium.Math.toRadians(item.pitch || 0), Cesium.Math.toRadians(item.roll || 0));
                // 生成一个函数,该函数从以提供的原点为中心的参考帧到提供的椭圆体的固定参考帧计算4x4变换矩阵。
                let fixedFrame = Cesium.Transforms.localFrameToFixedFrameGenerator('north', 'west');
                handle = await Cesium.Model.fromGltfAsync({
                    url: item.url!,
                    modelMatrix: Cesium.Transforms.headingPitchRollToFixedFrame(position, hpRoll, Cesium.Ellipsoid.WGS84, fixedFrame),
                    scale: item.scale || 1,
                });
                handle.show = show;
                this.viewer.scene.primitives.add(handle)
                this.viewer.camera.flyTo({
                    destination: Cesium.Cartesian3.fromDegrees(item.lon, item.lat, 100),
                    duration: 2
                })
                break;

4、3dtiles配置与加载方式。由于本示例中的3dtiles 数据高度较低,这里需要将其高度提升了500米。后续会出一个动态调整3dtiles位置的章节,敬请期待。


//3dtile测试数据
export const model3dtilesTestInfo:ILayerItem = {
    id: LayerIdFlag.MODEL_3DTILES_TEST,
    name: "model3dtilesTest",
    type: "3dtiles",
    url: "/testdata/dayanta/tileset.json",
    layer: "model3dtilesTest"
}

 case '3dtiles':
                handle = await Cesium.Cesium3DTileset.fromUrl(item.url!);                
                (handle as Cesium.Cesium3DTileset).show = show;
                this.viewer.scene.primitives.add(handle)
                this.viewer.zoomTo(handle, new Cesium.HeadingPitchRange(0.0, -0.3, 0.0))
               
                let cartographic = Cesium.Cartographic.fromCartesian(
                    handle.boundingSphere.center
                );
                let surface = Cesium.Cartesian3.fromRadians(
                    cartographic.longitude,
                    cartographic.latitude,
                    0.0
                );
                let offset = Cesium.Cartesian3.fromRadians(
                    cartographic.longitude,
                    cartographic.latitude,
                    500.0
                );
                let translation = Cesium.Cartesian3.subtract(
                    offset,
                    surface,
                    new Cesium.Cartesian3()
                );
                handle.modelMatrix = Cesium.Matrix4.fromTranslation(translation);
                break

5、czml数据配置与加载方式


export const czmlTestInfo:ILayerItem = {
    id: LayerIdFlag.CZML_TEST,
    name: "czmlTest",
    type: "czml",
    url: "/testdata/simple.czml",
    layer: "czmlTest"
}

          case 'czml':
                handle = await Cesium.CzmlDataSource.load(item.url!);
                (handle as Cesium.CzmlDataSource).show = show
                this.viewer.dataSources.add(handle)
                 this.viewer.zoomTo(handle)
                break

转存失败,建议直接上传图片文件

6、数据删除。

    Remove(id: string): void {
        const item = this.layersIdMap.get(id)
        if (!item) return;
        const { type, handle } = item;
        switch (type) {
            case 'imagery_wms':
            case 'imagery_wmts':
            case 'imagery_xyz':
                this.viewer.imageryLayers.remove(handle)
                break
            case 'terrain':
                // 回到默认椭球
                this.viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider()
                break
            case 'geojson':
            case 'kml':
            case 'czml':
                this.viewer.dataSources.remove(handle)
                break;
            case '3dtiles':
            case 'glb':
                this.viewer.scene.primitives.remove(handle)
                break;
        }
        this.layersIdMap.delete(id);
    }

7、前端实现:

<template>
    <div class="custom-data-container">
        <span class="custom-data-title"> 自定义数据 </span>
        <div style="text-align: left;">
           
            <div class="data-items">
                <span>图层信息</span>
                <div>
                    <el-checkbox v-model="geojsonChecked" label="geojson" size="large" @change="changeGeojson"/>
                    <el-checkbox v-model="kmlChecked" label="KML" size="large" @change="changeKml"/>
                    <el-checkbox v-model="glbChecked" label="glb" size="large" @change="changeGlb"/>
                    <el-checkbox v-model="model3dtilesChecked" label="3dtiles" size="large" @change="changeModel3dtiles"/>
                    <el-checkbox v-model="czmlChecked" label="czml" size="large" @change="changeCzml"/>
                </div>
            </div>
        </div>
    </div>
</template>
<script lang="ts" setup>
import { czmlTestInfo, geojsonTestInfo, glbTestInfo, kmlTestInfo, LayerIdFlag, model3dtilesTestInfo } from '@/system/LayerManager/LayerConfig'
import LayerManager from '@/system/LayerManager/LayerManager'
import CesiumViewer from '@/Viewer/CesiumViewer'
const viewer = CesiumViewer.viewer
let mLayerManager: LayerManager | null = null
const geojsonChecked = ref(false)
const kmlChecked = ref(false)
const glbChecked = ref(false)
const model3dtilesChecked = ref(false)
const czmlChecked = ref(false)
onMounted(() => {
    mLayerManager = LayerManager.getInstance(viewer!)
})
const changeGeojson = (val: string | number | boolean) => {
  if (val) {
    // 加载geojson图层
    mLayerManager?.Add(geojsonTestInfo)
  } else {
    // 移除geojson图层
    mLayerManager?.Remove(LayerIdFlag.GEOMSON_TEST)
  }
}
const changeKml = (val: string | number | boolean) => {
  if (val) {
    // 加载kml图层
    mLayerManager?.Add(kmlTestInfo)
  } else {
    // 移除kml图层
    mLayerManager?.Remove(LayerIdFlag.KML_TEST)
  }
}
const changeGlb = (val: string | number | boolean) => {
  if (val) {
    // 加载glb图层
    mLayerManager?.Add(glbTestInfo)
  } else {
    // 移除glb图层
    mLayerManager?.Remove(LayerIdFlag.GLB_TEST)
  }
}
const changeModel3dtiles = (val: string | number | boolean) => {
  if (val) {
    // 加载3dtiles图层
    mLayerManager?.Add(model3dtilesTestInfo)
  } else {
    // 移除3dtiles图层
    mLayerManager?.Remove(LayerIdFlag.MODEL_3DTILES_TEST)
  }
}
const changeCzml = (val: string | number | boolean) => {
  if (val) {
    // 加载czml图层
    mLayerManager?.Add(czmlTestInfo)
  } else {
    // 移除czml图层
    mLayerManager?.Remove(LayerIdFlag.CZML_TEST)
  }
}
</script>
<style lang="scss" scoped>
.custom-data-container {
    padding: 10px;
    .custom-data-title {
        font-size: 16px;
        font-weight: bold;
        margin-bottom: 10px;
    }
    
    .data-items {
        .el-checkbox {
            margin-left: 20px;
            height: 30px;
        }
        :deep(.el-checkbox__label) {
            color: rgb(206, 194, 194);
        }
    }
}
</style>

更多代码请查看原文Cesium+Vue3学习系列(14)---Cesium加载与删除geojson、kml、glb、3dtiles、czml数据

H5 WebView 文件下载到手机中(仅安卓与 iOS)

作者 前端一课
2025年11月18日 15:04

H5 WebView 文件下载(仅安卓与 iOS)

原理

  • 使用 H5+ 原生接口 plus.downloader.createDownload 将文件下载到本地;下载完成后通过 plus.runtime.openFile 打开。
  • 不同平台保存路径不同:
    • Android:保存到系统公共 Download 目录(需要存储权限)。
    • iOS:保存到应用沙箱 _doc 持久目录(无需额外权限)。

使用方法(代码)

  • 页面按钮“原生下载PDF”触发函数:src/views/w-success.vue:323
  • 关键实现(节选):
// src/views/w-success.vue:323-388
const downloadByPlus = async () => {
  const p = (window as any).plus;
  if (!p) { alert("当前不在App环境"); return; }
  if (isDownloading.value) return;
  isDownloading.value = true;
  downloadStatus.value = "原生下载中...";

  try {
    const fileOnly = getFileName(downloadUrl);
    const isAndroid = p.os && p.os.name === "Android";
    const isiOS = p.os && p.os.name === "iOS";
    const filename = isAndroid
      ? `file:///storage/emulated/0/Download/${fileOnly}`
      : isiOS
      ? `_doc/${fileOnly}`
      : `_downloads/${fileOnly}`;

    const startDownload = () =>
      p.downloader.createDownload(
        downloadUrl,
        { filename },
        (d: any, status: number) => {
          isDownloading.value = false;
          if (status === 200) {
            let localPath = "";
            try { localPath = p.io.convertLocalFileSystemURL(d.filename); } catch (err) { localPath = ""; }
            downloadStatus.value = localPath ? `下载完成,路径:${localPath}` : "下载完成";
            try { p.runtime.openFile(d.filename); } catch (e: any) { downloadStatus.value = `${downloadStatus.value},打开失败: ${e?.message || e}`; }
          } else {
            downloadStatus.value = `下载失败,状态码:${status}`;
          }
        }
      ).start();

    if (isAndroid) {
      try {
        await new Promise((resolve, reject) => {
          p.android.requestPermissions(
            ["android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.READ_EXTERNAL_STORAGE"],
            () => resolve(null),
            (err: any) => reject(err)
          );
        });
      } catch (err: any) {
        isDownloading.value = false;
        downloadStatus.value = `缺少存储权限: ${err?.message || err}`;
        return;
      }
    }
    startDownload();
  } catch (e: any) {
    isDownloading.value = false;
    downloadStatus.value = `原生下载失败: ${e?.message || e}`;
  }
};

保存路径

  • Android:/storage/emulated/0/Download/<文件名>(绝对路径显示为 file:///storage/emulated/0/Download/<文件名>)。
  • iOS:_doc/<文件名>(绝对路径通过 plus.io.convertLocalFileSystemURL 转换后显示为 file:///.../Documents/<文件名> 等沙箱路径)。

平台要求

  • Android:需要申请 WRITE_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGE 权限;代码中已请求(src/views/w-success.vue:356-371)。
  • iOS:若下载地址为 http://,需在原生打包的 ATS 配置中允许该域名的非 HTTPS 访问;文件仅可写入应用沙箱。

在Cesium中实现飘动的红旗

作者 子禾丶
2025年11月18日 14:49

smr4d-ls7t2.gif

前言

实现飘动红旗的效果整体分两部,一是利用三角函数的周期性让红旗摆动起来,二是根据每个片元的摆动幅度来计算对应位置的阴影
这是我在一个园区项目中收到的需求,在此记录及分享实现过程。

基础场景搭建(创建cesium场景和必要的实体)

这里使用gltf模型作为红旗,因为需要获得平滑的摆动效果,因此使用的模型面数较多,同时为了旗子与旗杆可以使用相同的坐标位置,我将模型的定位锚地放到了左上角(见下图,来自建模软件blender)。同样的,飘动的功能也可以手动创建Cesium中polygonrectangle实体来完成,核心部分与使用gltf模型无异。
image.png 创建基础场景

Cesium.Ion.defaultAccessToken = "your token";
const viewer = new Cesium.Viewer("cesiumContainer");
viewer.camera.setView({
    destination: Cesium.Cartesian3.fromDegrees(116.39122232836966, 39.90701265936752, 4.813199462406734),
    orientation: {
        heading: Cesium.Math.toRadians(26.754499635799313),
        pitch: Cesium.Math.toRadians(5.094600937875728),
        roll: 0,
    },
});
const modelPosition = [116.39124568344667, 39.90705858625655, 6]
//绘制旗子
const flag = viewer.entities.add({
    position: Cesium.Cartesian3.fromDegrees(...modelPosition),
    model: {
        uri: '../source/models/旗子/旗子.gltf',
    },
});
//绘制旗杆
viewer.entities.add({
        position: Cesium.Cartesian3.fromDegrees(modelPosition[0], modelPosition[1]),
        ellipse: {
            semiMinorAxis: 0.01,
            semiMajorAxis: 0.01,
            extrudedHeight: modelPosition[2] + 0.05,
            material: Cesium.Color.fromCssColorString('#eee'),
        },
    });

image.png

让旗子飘动起来

请注意,下文中所有着色器的坐标控制都是基于模型自身的局部坐标系。如果使用不同的模型,可能需要根据模型的具体坐标系统调整相关参数。

const customShader = new Cesium.CustomShader({
    uniforms: {
        // 自增变量,让动画持续执行
        u_time: {
            type: Cesium.UniformType.FLOAT,
            value: 0.0
        },
        u_texture: {
            type: Cesium.UniformType.SAMPLER_2D,
            value: new Cesium.TextureUniform({
                url: "../source/models/旗子/hongqi.jpg",
            })
        }
    },
    varyings: {
        // 旗子的片元偏移量需要传递给片元着色器计算阴影,因此定义为varying变量
        v_offset: Cesium.VaryingType.FLOAT
    },
    vertexShaderText: `
        void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) {
            // 根据模型uv坐标的x和y坐标来确定摆动的频率(对应sin曲线的波频)
            // 这里控制波频时,分别用到了x和y轴坐标,并让x坐标权重大于y坐标,使得摆动更加自然
            // 最后乘以0.13是为了控制摆动的幅度(对应sin曲线的波高)
            float offset = sin(vsInput.attributes.texCoord_0.x * 8.0 +  vsInput.attributes.texCoord_0.y * 1.5 - u_time) * 0.13;
            v_offset = offset - offset * smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x);
            // 为片元赋予新的x坐标,新的x坐标为原始x坐标加上摆动偏移量
            vsOutput.positionMC.x += vsOutput.positionMC.x + v_offset;
            // 因为旗子在x方向上有前后摆动,因此在视觉上z轴应当适当缩短一些
            vsOutput.positionMC.z *= 0.95;
        }`,
    fragmentShaderText: `
        void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
                
        }`
})
flag.model.customShader = customShader;
viewer.clock.onTick.addEventListener(function () {
    // 使动画持续进行
    customShader.uniforms.u_time.value += 0.1;
});

rpwhz-mq5ij.gif

从上图可以看出,虽然旗子已经实现了摆动效果,但与预期不符:靠近旗杆的一侧本应保持不动。接下来进一步优化顶点着色器代码。

void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) {
    float offset = sin(vsInput.attributes.texCoord_0.x * 8.0 +  vsInput.attributes.texCoord_0.y * 1.5 - u_time) * 0.13;
    // 这是关键的一步,使用平滑阶梯函数smoothstep函数来控制摆动的范围
    // smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x)表达式在uv的x轴坐标靠近起点时返回1,到达x轴的0.4时返回0
    // 再用offset减去offset乘以smoothstep函数的结果,就可以得到在x轴坐标靠近0时,offset值为0的效果,往x轴的0.4靠近时再渐渐回到完全的摆动
    // 关于smoothstep函数的更多信息,请参考https://zhuanlan.zhihu.com/p/157758600
    v_offset = offset - offset * smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x);
    vsOutput.positionMC.x += vsOutput.positionMC.x + v_offset;
    vsOutput.positionMC.z *= 0.95;
}

此时smoothstep函数已经把旗杆一侧固定住了。

s6cp1-0sa4x.gif

为旗子添加贴图和阴影

void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
    // 这一步是为了uv贴图的正确映射
    fsInput.attributes.texCoord_0.y *= -1.0;
    // (1.0 - v_offset * 3.0)表达式用来决定片元的亮度,往x轴的正方向偏移越大,这个表达式输出的值越小,则越暗
    // v_offset * 3.0是为了放大偏移量从而增大明暗对比
    // 贴图颜色乘以亮度系数就是最终色(这里控制亮度系数的范围在0-1之间,不去增加凸起部分的亮度会更逼真一些)
    vec4 color = texture(u_texture,fsInput.attributes.texCoord_0) * min((1.0 - v_offset * 2.0),1.0);
    material.diffuse=vec3(color.rgb);     
}

83e16bd946d643dc8374d800d678666e.png

上图可以看出,旗子凹陷部分已经有了阴影,但是此时阴影和片元的偏移程度为线性关系,阴影处的对比不够强烈,下面分享另一种阴影算法。

优化阴影

接下来通过实现阴影随片元偏移量的指数级增长来增强阴影部分的对比度,使效果更加逼真。

void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
    // 计算片元偏移量的平方用于后续的阴影计算
    float offsetSquared = v_offset * v_offset;
    // 获取片元偏移量的符号,用于在后续的计算中保留偏移量的正负性
    float offsetSign = v_offset >= 0.0 ? 1.0 : -1.0;
    fsInput.attributes.texCoord_0.y *= -1.0;
    // 1.0 - offsetSquared * offsetSign * 30.0表达式用来决定片元的亮度,原理与上面的着色器一致
    // 不过此时片元的亮度与偏移量为指数级增长关系,会在阴影区域获得更加大的反差,增强逼真度
    vec4 color = texture(u_texture, fsInput.attributes.texCoord_0) * min(1.0 - offsetSquared * offsetSign * 30.0, 1.0);
    material.diffuse=vec3(color.rgb);
}

比刚才逼真多了

8f311d85487d462fa5cac31a6f71550e.png

smr4d-ls7t2.gif

完整的CustomShader代码

const customShader = new Cesium.CustomShader({
    uniforms: {
        u_time: {
            type: Cesium.UniformType.FLOAT,
            value: 0.0
        },
        u_texture: {
            type: Cesium.UniformType.SAMPLER_2D,
            value: new Cesium.TextureUniform({
                url: "../source/models/旗子/hongqi.jpg",
            })
        }
    },
    varyings: {
        v_offset: Cesium.VaryingType.FLOAT
    },
    vertexShaderText: `
            void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) {
                // 根据模型uv坐标的x和y坐标来确定摆动的频率(对应sin曲线的波频)
                // 这里控制波频时,分别用到了x和y轴坐标,并让x坐标权重大于y坐标,使得摆动更加自然
                // 最后乘以0.13是为了控制摆动的幅度(对应sin曲线的波高)
                float offset = sin(vsInput.attributes.texCoord_0.x * 8.0 +  vsInput.attributes.texCoord_0.y * 1.5 - u_time) * 0.13;
                // 这是关键的一步,使用平滑阶梯函数smoothstep函数来控制摆动的范围
                // smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x)表达式在uv的x轴坐标靠近起点时返回1,到达x轴的0.4时返回0
                // 再用offset减去offset乘以smoothstep函数的结果,就可以得到在x轴坐标靠近0时,offset值为0的效果
                // 关于smoothstep函数的更多信息,请参考https://zhuanlan.zhihu.com/p/157758600
                v_offset = offset - offset * smoothstep(0.4, 0.0,  vsInput.attributes.texCoord_0.x);
                // 为片元赋予新的x坐标,新的x坐标为原始x坐标加上摆动偏移量
                vsOutput.positionMC.x += vsOutput.positionMC.x + v_offset;
                // 因为旗子在x方向上有前后摆动,因此在视觉上z轴应当适当缩短一些
                vsOutput.positionMC.z *= 0.95;
            }`,
    fragmentShaderText: `
            void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
                // 计算片元偏移量的平方用于后续的阴影计算
                float offsetSquared = v_offset * v_offset;
                // 获取片元偏移量的符号,用于在后续的计算中保留偏移量的正负性
                float offsetSign = v_offset >= 0.0 ? 1.0 : -1.0;
                fsInput.attributes.texCoord_0.y *= -1.0;
                // 1.0 - offsetSquared * offsetSign * 30.0表达式用来决定片元的亮度,原理与上面的着色器一致
                // 不过此时片元的亮度与偏移量为指数级增长关系,会在阴影区域获得更加大的反差,增强逼真度
                vec4 color = texture(u_texture, fsInput.attributes.texCoord_0) * min(1.0 - offsetSquared * offsetSign * 30.0, 1.0);
                material.diffuse=vec3(color.rgb);
            }`
})

总结

  1. 摆动核心
    用三角函数把 UV 坐标映射成随时间变化的偏移量,再用 smoothstep 把旗杆侧固定,就能让红旗只在自由端飘动。

  2. 阴影核心
    把顶点着色器算出的偏移量 v_offset 传进片元着色器,用“1 − 偏移量² × 符号 × 放大系数”做指数级压暗,褶皱阴影逼真立体。

  3. 扩展和思考

    • 如何使用噪声实现?
    • 如何为摆动的增加随机性?

祝玩旗愉快!

wangEditor5在vue中自定义菜单栏--格式刷,上传图片,视频功能

2025年11月18日 14:43

一、安装相关插件

npm install @wangeditor/editor
npm install @wangeditor/editor-for-vue

二、官方关键文档

  1. ButtonMenu:www.wangeditor.com/v5/developm…
  2. 注册菜单到wangEditor:自定义扩展新功能 | wangEditor
  3. insertKeys自定义功能的keys:www.wangeditor.com/v5/toolbar-…
  4. 自定义上传图片视频功能:菜单配置 | wangEditor
  5. 源码地址:GitHub - wangeditor-team/wangEditor: wangEditor —— 开源 Web 富文本编辑器

三、初始化编辑器(wangEdit.vue) 

<template>
  <div style="border: 1px solid #ccc">
    <Toolbar
      style="border-bottom: 1px solid #ccc"
      :editor="editor"
      :defaultConfig="toolbarConfig"
      :mode="mode"
    />
    <Editor
      style="height: 500px; overflow-y: hidden"
      v-model="html"
      :defaultConfig="editorConfig"
      :mode="mode"
      @onCreated="onCreated"
      @onChange="onChange"
    />
  </div>
</template>

<script>
// import Location from "@/utils/location";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import { Boot, IModuleConf, DomEditor } from "@wangeditor/editor";
import { getToken } from "@/utils/auth";
import MyPainter from "./geshi";
const menu1Conf = {
  key: "geshi", // 定义 menu key :要保证唯一、不重复(重要)
  factory() {
    return new MyPainter(); // 把 `YourMenuClass` 替换为你菜单的 class
  },
};
const module = {
  // JS 语法
  menus: [menu1Conf],

  // 其他功能,下文讲解...
};
Boot.registerModule(module);
export default {
  components: { Editor, Toolbar },
  props: {
    relationKey: {
      type: String,
      default: "",
    },
  },
  created() {
    console.log(this.editorConfig.MENU_CONF.uploadImage.meta.activityKey);
  },
  data() {
    return {
      // 富文本实例
      editor: null,
      // 富文本正文内容
      html: "",
      // 编辑器模式
      mode: "default", // or 'simple'
      // 工具栏配置
      toolbarConfig: {
        //新增菜单
        insertKeys: {
          index: 32,
          keys: ["geshi"],
        },
        //去掉网络上传图片和视频
        excludeKeys: ["insertImage", "insertVideo"],
      },
      // 编辑栏配置
      editorConfig: {
        placeholder: "请输入相关内容......",
        // 菜单配置
        MENU_CONF: {
          // ===================
          // 上传图片配置
          // ===================
          uploadImage: {
            // 文件名称
            fieldName: "contentAttachImage",
            server: Location.serverPath + "/editor-upload/upload-image",
            headers: {
              Authorization: "Bearer " + getToken(),
            },
            meta: {
              activityKey: this.relationKey,
            },
            // 单个文件的最大体积限制,默认为 20M
            maxFileSize: 20 * 1024 * 1024,
            // 最多可上传几个文件,默认为 100
            maxNumberOfFiles: 10,
            // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
            allowedFileTypes: ["image/*"],
            // 跨域是否传递 cookie ,默认为 false
            withCredentials: true,
            // 超时时间,默认为 10 秒
            timeout: 5 * 1000,
            // 自定义插入图片操作
            customInsert: (res, insertFn) => {
              if (res.errno == -1) {
                this.$message.error("上传失败!");
                return;
              }
              insertFn(Location.serverPath + res.data.url, "", "");
              this.$message.success("上传成功!");
            },
          },
          // =====================
          // 上传视频配置
          // =====================
          uploadVideo: {
            // 文件名称
            fieldName: "contentAttachVideo",
            server: Location.serverPath + "/editor-upload/upload-video",
            headers: {
              Authorization: "Bearer " + getToken(),
            },
            meta: {
              activityKey: this.relationKey,
            },
            // 单个文件的最大体积限制,默认为 60M
            maxFileSize: 60 * 1024 * 1024,
            // 最多可上传几个文件,默认为 100
            maxNumberOfFiles: 3,
            // 选择文件时的类型限制,默认为 ['video/*'] 。如不想限制,则设置为 []
            allowedFileTypes: ["video/*"],
            // 跨域是否传递 cookie ,默认为 false
            withCredentials: true,
            // 超时时间,默认为 10 秒
            timeout: 15 * 1000,
            // 自定义插入图片操作
            customInsert: (res, insertFn) => {
              if (res.errno == -1) {
                this.$message.error("上传失败!");
                return;
              }
              insertFn(Location.serverPath + res.data.url, "", "");
              this.$message.success("上传成功!");
            },
          },
        },
      },

      // ===== data field end =====
    };
  },
  methods: {
    // =============== Editor 事件相关 ================
    // 1. 创建 Editor 实例对象
    onCreated(editor) {
      this.editor = Object.seal(editor); // 一定要用 Object.seal() ,否则会报错
      this.$nextTick(() => {
        const toolbar = DomEditor.getToolbar(this.editor);
        const curToolbarConfig = toolbar.getConfig();
        console.log("【 curToolbarConfig 】-39", curToolbarConfig);
      });
    },
    // 2. 失去焦点事件
    onChange(editor) {
      this.$emit("change", this.html);
    },

    // =============== Editor操作API相关 ==============
    insertText(insertContent) {
      const editor = this.editor; // 获取 editor 实例
      if (editor == null) {
        return;
      }
      // 执行Editor的API插入
      editor.insertText(insertContent);
    },

    // =============== 组件交互相关 ==================
    // closeEditorBeforeComponent() {
    //   this.$emit("returnEditorContent", this.html);
    // },
    closeContent(){
        this.html=''
    },
    // ========== methods end ===============
  },
  mounted() {
    // ========== mounted end ===============
  },
  beforeDestroy() {
    const editor = this.editor;
    if (editor == null) {
      return;
    }
    editor.destroy();
    console.log("销毁编辑器!");
  },
};
</script>
<style lang="scss" scoped>
// 对默认的p标签进行穿透
::v-deep .editorStyle .w-e-text-container [data-slate-editor] p  {
  margin: 0 !important;
}
</style>
<style src="@wangeditor/editor/dist/css/style.css"></style>
自定义上传图片接口
 uploadImage: {
                        // 文件名称
                        fieldName: "contentAttachImage",
                        // server: '/api/v1/public/uploadFile',
                        headers: {
                            Authorization: "Bearer " + getToken(),
                        },
                        meta: {
                            activityKey: this.relationKey,
                        },
                        // 单个文件的最大体积限制,默认为 20M
                        maxFileSize: 20 * 1024 * 1024,
                        // 最多可上传几个文件,默认为 100
                        maxNumberOfFiles: 10,
                        // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
                        allowedFileTypes: ["image/*"],
                        // 跨域是否传递 cookie ,默认为 false
                        withCredentials: true,
                        // 超时时间,默认为 10 秒
                        timeout: 5 * 1000,
                         这里设置
                        customUpload: async (file, insertFn) => {
                            console.log(file, "file");
                            let formData = new FormData()
                            const sub = "order";
                            formData.append('file', file)
                            formData.append("sub", sub);
                            formData.append("type", "1");
                            let res = await getUploadImg(formData)
                            insertFn(res.data.full_path, '', '');
                        },
                        customInsert: (res, insertFn) => {
                            if (res.errno == -1) {
                                this.$message.error("上传失败!");
                                return;
                            }
                            // insertFn(res.data.url, "", "");
                            this.$message.success("上传成功!");
                        },
                    },

四、格式刷功能类js文件

import {
  SlateEditor,
  SlateText,
  SlateElement,
  SlateTransforms,
  DomEditor,
  //   Boot,
} from "@wangeditor/editor";
// Boot.registerMenu(menu1Conf);
import { Editor } from "slate";
export default class MyPainter {
  constructor() {
    this.title = "格式刷"; // 自定义菜单标题
    // 这里是设置格式刷的样式图片跟svg都可以,但是注意要图片大小要小一点,因为要应用到鼠标手势上
    this.iconSvg = ``;
    this.tag = "button"; //注入的菜单类型
    this.savedMarks = null; //保存的样式
    this.domId = null; //这个可要可不要
    this.editor = null; //编辑器示例
    this.parentStyle = null; //储存父节点样式
    this.mark = "";
    this.marksNeedToRemove = []; // 增加 mark 的同时,需要移除哪些 mark (互斥,不能共存的)
  }
  clickHandler(e) {
    console.log(e, "e"); //无效
  }
  //添加或者移除鼠标事件
  addorRemove = (type) => {
    const dom = document.body;
    if (type === "add") {
      dom.addEventListener("mousedown", this.changeMouseDown);
      dom.addEventListener("mouseup", this.changeMouseup);
    } else {
      //赋值完需要做的清理工作
      this.savedMarks = undefined;
      dom.removeEventListener("mousedown", this.changeMouseDown);
      dom.removeEventListener("mouseup", this.changeMouseup);
      document.querySelector("#w-e-textarea-1").style.cursor = "auto";
    }
  };

  //处理重复键名值不同的情况
  handlerRepeatandNotStyle = (styles) => {
    const addStyles = styles[0];
    const notVal = [];
    for (const style of styles) {
      for (const key in style) {
        const value = style[key];
        if (!addStyles.hasOwnProperty(key)) {
          addStyles[key] = value;
        } else {
          if (addStyles[key] !== value) {
            notVal.push(key);
          }
        }
      }
    }
    for (const key of notVal) {
      delete addStyles[key];
    }
    return addStyles;
  };

  // 获取当前选中范围的父级节点
  getSelectionParentEle = (type, func) => {
    if (this.editor) {
      const parentEntry = SlateEditor.nodes(this.editor, {
        match: (node) => SlateElement.isElement(node),
      });
      let styles = [];
      for (const [node] of parentEntry) {
        styles.push(this.editor.toDOMNode(node).style); //将node对应的DOM对应的style对象加入到数组
      }
      styles = styles.map((item) => {
        //处理不为空的style
        const newItem = {};
        for (const key in item) {
          const val = item[key];
          if (val !== "") {
            newItem[key] = val;
          }
        }
        return newItem;
      });
      type === "get"
        ? func(type, this.handlerRepeatandNotStyle(styles))
        : func(type);
    }
  };

  //获取或者设置父级样式
  getorSetparentStyle = (type, style) => {
    if (type === "get") {
      this.parentStyle = style; //这里是个样式对象 例如{textAlign:'center'}
    } else {
      SlateTransforms.setNodes(
        this.editor,
        { ...this.parentStyle },
        {
          mode: "highest", // 针对最高层级的节点
        }
      );
    }
  };

  //这里是将svg转换为Base64格式
  addmouseStyle = () => {
    const icon = ``; // 这里是给鼠标手势添加图标
    // 将字符串编码为Base64格式
    const base64String = btoa(icon);
    // 生成数据URI
    const dataUri = `data:image/svg+xml;base64,${base64String}`;
    // 将数据URI应用于鼠标图标
    document.querySelector(
      "#w-e-textarea-1"
    ).style.cursor = `url('${dataUri}'), auto`;
  };
  changeMouseDown = () => {}; //鼠标落下

  changeMouseup = () => {
    //鼠标抬起
    if (this.editor) {
      const editor = this.editor;
      const selectTxt = editor.getSelectionText(); //获取文本是否为null
      if (this.savedMarks && selectTxt) {
        //先改变父节点样式
        this.getSelectionParentEle("set", this.getorSetparentStyle);
        // 获取所有 text node
        const nodeEntries = SlateEditor.nodes(editor, {
          //nodeEntries返回的是一个迭代器对象
          match: (n) => SlateText.isText(n), //这里是筛选一个节点是否是 text
          universal: true, //当universal为 true 时,Editor.nodes会遍历整个文档,包括根节点和所有子节点,以匹配满足条件的节点。当universal为 false 时,Editor.nodes只会在当前节点的直接子节点中进行匹配。
        });
        // 先清除选中节点的样式
        for (const node of nodeEntries) {
          const n = node[0]; //{text:xxxx}
          const keys = Object.keys(n);
          keys.forEach((key) => {
            if (key === "text") {
              // 保留 text 属性
              return;
            }
            // 其他属性,全部清除
            SlateEditor.removeMark(editor, key);
          });
        }
        // 再赋值新样式
        for (const key in this.savedMarks) {
          if (Object.hasOwnProperty.call(this.savedMarks, key)) {
            const value = this.savedMarks[key];
            editor.addMark(key, value);
          }
        }
        this.addorRemove("remove");
      }
    }
  };

  getValue(editor) {
    // return "MyPainter"; // 标识格式刷菜单
    const mark = this.mark;
    console.log(mark, "mark");
    const curMarks = Editor.marks(editor);
    // 当 curMarks 存在时,说明用户手动设置,以 curMarks 为准
    if (curMarks) {
      return curMarks[mark];
    } else {
      const [match] = Editor.nodes(editor, {
        // @ts-ignore
        match: (n) => n[mark] === true,
      });
      return !!match;
    }
  }

  isActive(editor, val) {
    const isMark = this.getValue(editor);
    return !!isMark;
    //  return !!DomEditor.getSelectedNodeByType(editor, "geshi");
    // return false;
  }

  isDisabled(editor) {
    //是否禁用
    return false;
  }
  exec(editor, value) {
    //当菜单点击后触发
    // console.log(!this.isActive());
    console.log(value, "value");
    this.editor = editor;
    this.domId = editor.id.split("-")[1]
      ? `w-e-textarea-${editor.id.split("-")[1]}`
      : undefined;
    if (this.isDisabled(editor)) return;
    const { mark, marksNeedToRemove } = this;
    if (value) {
      // 已,则取消
      editor.removeMark(mark);
    } else {
      // 没有,则执行
      editor.addMark(mark, true);
      this.savedMarks = SlateEditor.marks(editor); // 获取当前选中文本的样式
      this.getSelectionParentEle("get", this.getorSetparentStyle); //获取父节点样式并赋值
    //   this.addmouseStyle(); //点击之后给鼠标添加样式
      this.addorRemove("add"); //处理添加和移除事件函数
      // 移除互斥、不能共存的 marks
      if (marksNeedToRemove) {
        marksNeedToRemove.forEach((m) => editor.removeMark(m));
      }
    }
    if (
      editor.isEmpty() ||
      editor.getHtml() == "<p><br></p>" ||
      editor.getSelectionText() == ""
    )
      return; //这里是对没有选中或者没内容做的处理
  }
}

五、页面应用组件

 <el-form-item label="内容">
 <WangEdit v-model="form.content" ref="wangEdit"  @change="change"></WangEdit>
  </el-form-item>


// js
const WangEdit = () => import("@/views/compoments/WangEdit.vue");
export default {
  name: "Notice",
  components: {
    WangEdit,
  },
    data(){
    return{
          form:{
         }
    }
    },

 methods: {
     change(val) {
            console.log(val,'aa');
            this.form.content=val
        },
     // 取消按钮
    cancel() {
      this.open = false;
      this.form={};
      this.$refs.wangEdit.closeContent();
    },
}

转载:wangEditor5在vue中自定义菜单栏--格式刷,上传图片,视频功能_vue.js_liang04273-Vue

Apple同款SVG,怎么写出来?手写+编辑器,两张方法都能搞定!

2025年11月18日 14:40

编辑

有时候刷到那种很克制、很高级的轮播——没有夸张的左右整屏滑动,只是几张卡片在视野里轻轻错位一下,你大脑里就会自动补一句:

嗯,这味儿有点 Apple。

这类效果不一定要上 JS / CSS 大动干戈,很多公众号里的“高级轮播”,其实就是一段 纯 SVG + SMIL 动画。下面我们就拆一段实际在用的 4 图轮播代码,看它是怎么靠几行<animateTransform> 做出这种“款款而来”的感觉的。
*本文代码借鉴学习了一款现成的SVG编辑器 -  E2 编辑器做练习,只是做一下学习分享。

下文的核心结构来自这样一段代码(只保留和动效相关的部分):

<svg viewBox="0 0 0 0" width="100%">
  <g>
    <foreignObject x="0" y="0" width="0" height="0">
      <svg
        style='display:block; background-image:url(""); background-size:100% auto;'
        viewBox="0 0 0 0" width="100%">
      </svg>
    </foreignObject>
  </g>

  <g>
    <foreignObject ...> ... </foreignObject>
    <animateTransform
      attributeName="transform"
      type="translate"
      repeatCount="indefinite"
      values="0 0; 0 0; 0 0; 0 0; 0 0; 0 0; 0 0; -80 0; 0 0;"
      begin="0.3s"
      dur="4s"
      calcMode="spline"
      keySplines="0.42 0 0.58 1.0; ...">
    </animateTransform>
  </g>

  <!-- 另外 3 个 <g>,结构类似,只是 values 不同 -->
</svg>

一、整体结构:一个 <svg> + 多个 <g> = 多个 slide

这段代码的结构非常简单粗暴:

  • 最外层是一个 <svg>width="100%",跟着容器自动铺满;
  • 里面有 多个 <g> 分组,每个分组对应一张“幻灯片”;
  • 每个分组里都有一个 <foreignObject>,里面再包一段 <svg>,这段内嵌 svg 只干一件事:
    用 background-image 把真正的图片铺上去。

换句话说,这个轮播:

  • 没有 <image> 标签;
  • 没有 <img> 标签;
  • 就是把 图片当作 CSS 背景,挂在一个 svg 里。

这种写法在公众号场景里有个好处:

  • 版式层是 SVG 控制的(可以做动效);
  • 图像展示层直接走 CSS 背景(好替换、好裁剪)。

你要换自己的图,只需要改这一段的 URL:

<svg
  style='display:block; 
         background-image:url("https://你的图片地址.jpg");
         background-size:100% auto;
         background-repeat:no-repeat;'>
</svg>

二、轮播核心:用 SMIL 的 <animateTransform> 做平移

真正让这几个 slide “活起来”的,就是每个 <g> 下面那条 animateTransform

典型一条长这样:

<animateTransform
  attributeName="transform"
  type="translate"
  repeatCount="indefinite"
  values="0 0; 0 0; 0 0; 0 0; 0 0; 0 0; 0 0; -80 0; 0 0;"
  begin="0.3s"
  dur="4s"
  calcMode="spline"
  keySplines="
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0;
    0.42 0 0.58 1.0">
</animateTransform>

逐项看:

  • attributeName="transform"
    意味着这条动画会直接改 <g> 的 transform 属性;

  • type="translate"
    指定 transform 的类型是平移,相当于在做 translate(x, y)

  • repeatCount="indefinite"
    无限循环播放,保证轮播不停止;

  • dur="4s"
    一轮动画总时长 4 秒;

  • begin="0.3s"
    整体开始时间延迟了 0.3 秒,避免页面一加载上来就立刻抖动;

  • values="..."
    这是整段的关键帧列表,每一对数字代表一个平移坐标:

    • 0 0:停在初始位置;
    • -80 0:往左移动 80 个单位;
    • 接着再回到 0 0

values 里之所以有一长串 0 0,其实是在拉长“静止时间” ,让卡片绝大部分时间都稳稳地停在中间,只在时间轴尾巴那一段轻轻侧移一下再回来。

这就是“优雅”的第一层来源:
不是一直在明显地滑,而是大部分时间在静止、小部分时间在微动


三、动起来为什么看着“高级”?关键在于缓动 + 细小位移

1. 微小位移:只移动 80,而不是整屏 100%

可以对比一下常见轮播:

  • 普通“整屏轮播”:一张图从 0% 滑到 -100%,下一张从 100% 滑到 0
  • 这段 SVG 轮播:只平移 -80 或 +80 的距离。

也就是说,它并不是在做「换图」,而是在做「轻微错位」:

  • 用户视角里,一直是同一张图为主;
  • 偶尔轻轻滑动一点点,有一种“轻微呼吸”的感觉;
  • 视觉上更接近 Apple 官网那些“轻轻抖一抖”的动效,而不是 APP 里的 banner 跑马灯。

2. 贝塞尔缓动:keySplines="0.42 0 0.58 1.0"

calcMode="spline" + keySplines="0.42 0 0.58 1.0" 这对组合,其实就是 SVG 版本的 CSS cubic-bezier(0.42, 0, 0.58, 1),也就是非常经典的 ease-in-out

效果是:

  • 一开始慢慢起步(不是生硬地动);
  • 中间加速一点;
  • 结束前再慢慢刹车。

配合上前面说的“位移量不大”,就会得到一个很克制的动效——有存在感,但不抢内容风头

3. 多个 slide 共用同一时间轴

注意这 4 个 <g>

  • 都是 dur="4s" begin="0.3s" repeatCount="indefinite"
  • 唯一的区别是:每个 <g> 的 values 序列不一样。

换句话说:

  • 它们跑在同一条时间线上;
  • 但每一张图的“位移节奏”略有差异;
  • 最终呈现出来就是一个整体“呼吸”的卡片群,而不是单张 slide 独立乱飞。

这点非常像 Apple 官网那种「一组卡片跟着一个节奏,轮流被稍稍突出」的感觉。


四、想改成自己的轮播?几个实用的改法

如果你想在这个基础上做个自己的「苹果味轮播」,主要可以改 4 个方面:

1. 换成你自己的图片

找到每个 foreignObject 里的这段:

<svg
  style='display:block;
         background-image:url("https://你的图.jpg");
         background-size:100% auto;
         background-repeat:no-repeat;'>
</svg>

把 background-image 换成你的图地址就行。

想更“苹果一点”,可以准备几张同一系列、同一光感的产品图。

2. 调整位移幅度

所有轮播的空间错位,靠的就是 values 里的那几个 -80 0 / 80 0

  • 想动得更明显一点:可以改成 -160 0 / 160 0
  • 想更微妙一点:可以改成 -40 0 / 40 0

比如:

values="0 0; 0 0; 0 0; -40 0; 0 0; 0 0; 0 0; 0 0; 0 0"

3. 调整节奏和停留时间

几种常见玩法:

  • 整体放慢dur="4s" 改成 6s 或 8s

  • 让“静止”的比例更大:多复制几段 0 0

  • 只在最后 10% 时间里移动
    配合 keyTimes(如果你愿意加上一行):

    • 前 90% 的 keyTime 都是 0 0,最后 10% 才从 0 0 → -80 0 → 0 0

示意写法(扩展版):

<animateTransform
  attributeName="transform"
  type="translate"
  dur="8s"
  repeatCount="indefinite"
  values="0 0; 0 0; -80 0; 0 0"
  keyTimes="0;0.8;0.9;1"
  calcMode="spline"
  keySplines="
    0.42 0 0.58 1;
    0.42 0 0.58 1;
    0.42 0 0.58 1" />

4. 增加 / 减少图片数量

这个轮播没有“总数配置”,每张图就是一个 <g> ,所以:

  • 想多几张图:

    • 复制一份 <g> ... </g>
    • 按照原来的 pattern 写一条新的 values
  • 想只留 3 张图:

    • 删除其中一个 <g> 即可。

只要你保证:

  • 所有 <g> 的 dur 和 begin 一致;
  • values 之间有一点节奏差异(不要全部都一模一样);

整体看上去就还是那种「有秩序的小范围错位」。


五、小结

这个 4 图轮播本质上做了几件事:

  1. 用 SVG + foreignObject 搭了个“动效容器” ,图片当背景;
  2. 用 <animateTransform> + translate 做微位移,一次只挪几十个单位;
  3. 用 cubic-bezier 式的缓动曲线压住“廉价感” ,慢进慢出;
  4. 让所有 slide 套在同一时间轴上,做成整体的“呼吸”,而不是普通 banner 般机械的滑动。

看起来是“苹果味”的轮播,拆开就是一堆很朴素的 SVG 技巧。


最后一句:如果你懒得手写…

如果你不想自己去算这些 valueskeySplines 和 <g> 结构,也可以直接在 E2 编辑器里搜一下,组件名称叫「款款而来」,组件本身是免费使用的,用现成组件然后再对导出的 SVG 做二次改造,会轻松不少。

使用 E2 SVG 编辑器 — 基于 SVG 的“黑科技互动”可视化设计平台,上线1900+原创组件,支持积木式拖拽、组件复用与热区触发,已被 2 万+品牌应用于公众号、微博、活动专题制作,快速打造高互动图文、提升用户停留与完读率。

image.png 上传四张尺寸相同的图片,就可以直接预览效果,还可以点击获取代码后,在其他软件里进行二次编辑。还可以调整速度参数,如果购买了企业会员的话。

这个前端工具杀疯了!发布一周狂揽 10k Star,Snapchat 开源框架重新定义跨平台

作者 大知闲闲i
2025年11月18日 14:12

跨平台开发的战场,看似格局已定,实则暗流涌动。就在大家以为创新乏力之时,Snapchat 突然抛出一枚“重磅炸弹”——开源其内部框架 Valdi,瞬间点燃了整个前端圈。

这个横空出世的框架,仅用一周时间就在 GitHub 上狂揽 10k Star,它究竟有何魔力?答案在于:它选择了一条与所有主流方案都背道而驰的道路。

当其他框架还在纠结如何优化 WebView 或精简 Electron 时,Valdi 直接颠覆了游戏规则:让你用熟悉的前端语法编写界面,最终呈现的却是纯粹的原生组件。

技术革新:前端思维,原生性能

Valdi 的核心思路堪称“工程师的浪漫”:保留开发者友好的 UI 语法,同时直通原生渲染的性能巅峰。

具体来说:

  • 你用 TSX 编写界面逻辑

  • 但渲染层完全绕过 Web 技术

  • C++ 原生引擎直接接管视图渲染

  • 无需浏览器、无需 JavaScript Core、无需桥接损耗

这种架构带来的性能提升是惊人的:iOS demo 中状态切换仅需 2ms,几乎感知不到任何掉帧。

为什么开发者如此狂热?

三大关键因素引爆关注:

  1. 极低的学习门槛

    • 无需掌握 Swift/Kotlin

    • 不用深入理解原生渲染管线

    • TS + JSX 的技能直接迁移

  2. Snapchat 的技术背书

    • Snap 在图形渲染领域积累深厚

    • 经过内部大规模应用验证

    • 开源即成熟

  3. 精准命中行业痛点

    • 解决了 WebView 的性能瓶颈

    • 平衡了开发效率与运行性能

跨平台方案对比:Valdi 的独特定位

与 Electron、Tauri 相比,Valdi 选择了完全不同的技术路径:

  • Electron:Web 技术套壳,体验一致但资源消耗大

  • Tauri:轻量 WebView + Rust 后端,平衡性能与体积

  • Valdi:前端语法 → 原生渲染,追求极致性能

如果说跨平台发展分为三代:

  • 第一代:Web 套壳(Electron)

  • 第二代:轻量 WebView + 系统集成(Tauri)

  • 第三代:前端语法 + 原生渲染(Valdi)

Valdi 无疑代表着最新的技术演进方向。

适用场景分析

✅ 推荐使用:

  • 前端背景的移动端开发新手

  • 动画交互密集的复杂界面

  • 追求 TS 代码多端复用的团队

❌ 暂不推荐:

  • 依赖丰富插件生态的项目

  • 纯桌面端应用开发

  • 重度依赖现有 Web UI 库

需要注意的是,Valdi 目前仍处于 beta 阶段,生态建设尚需时日。

结语:重新想象跨平台开发

Valdi 的迅速走红,并非因为它已经完美无缺,而是它回答了一个让无数开发者魂牵梦绕的问题:

我们能否只用 TypeScript,就打造出原生性能的应用程序?

这个愿景一旦被提出,就再也无法被忽视。无论 Valdi 最终能否成为主流,它已经为跨平台开发赛道注入了新的活力,让我们看到了更多可能性。

在这个技术快速迭代的时代,最好的方案永远在下一个。Valdi 的出现,或许正是跨平台开发新一轮变革的起点。

element-plus主题配置及动态切换主题

作者 天外来物
2025年11月18日 14:02

创建vue项目的不同方式

pnpm create vite 使用于创建一些组件库,第三方库的时候

pnpm create vue 适用于vue项目,内部有一些基础的样式,vue的主题,

element-plus主题配置

按需引入element

1. scss变量自定义主题

分支:feature-element-theme-anxu-scss

src/styles/element/index.scss

/* just override what you need */
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'primary': (
      'base': green,
    ),
  )
);

vite.config.ts

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import ElementPlus from 'unplugin-element-plus/vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueDevTools(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [
        ElementPlusResolver({
          importStyle: 'sass',
        }),
      ],
    }),
    ElementPlus({
      useSource: true,
    }),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "@/styles/element/index.scss" as *;`,
      },
    },
  },
})

按需引入组件时,使用组件,会自动引入对应组件的样式,不用再main.ts中引入elememnt的全部样式。

主题配置只需要修改需要改变的颜色变量即可

可以看到,对应组件的颜色变量已经变化

image.png

2. css变量自定义主题

分支: feature-element-theme-anxu-css

src/styles/element/index.scss

:root {
  --el-color-primary: green;
}

vite.config.ts

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import ElementPlus from 'unplugin-element-plus/vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueDevTools(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [
        ElementPlusResolver({
          importStyle: 'sass',
        }),
      ],
    }),
    ElementPlus({
      useSource: true,
    }),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "@/styles/element/index.scss" as *;`,
      },
    },
  },
})

可以看到,css变量被注入到组件对应的scss文件顶部,从而让主题生效

image.png

全量引入element

scss全量引入

分支 feature-element-theme-all-scss

/* just override what you need */
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'primary': (
      'base': green,
    ),
  )
);

// If you just import on demand, you can ignore the following content.
// 如果你是全量导入,需要加下边这句,如果是按需引入,请注释掉下边这句
@use 'element-plus/theme-chalk/src/index.scss' as *;

main.ts

import './assets/main.css'

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import './styles/element/index.scss'
import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.use(ElementPlus)
app.mount('#app')

这时候可以看到样式被全部引入了

image.png

css全量引入

分支:feature-element-theme-all-css src/styles/element/index.css

:root {
  --el-color-primary: green;
}

main.ts

import './assets/main.css'

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './styles/element/index.css'
import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.use(ElementPlus)
app.mount('#app')

可以看到,element的css变量已经被覆盖了

image.png

动态主题

运行时动态切换主题,不能依赖scss变量

原因

SCSS 变量是编译时变量,在构建阶段就被替换掉了,在浏览器运行时无法再改变,所以无法用于“动态切换主题”。

动态主题应该使用css变量来实现

官方提供了一个切换主题的项目github.com/element-plu…

里面引入了一套暗黑模式的样式,然后通过 useToggle进行切换

分支 feature-element-theme-anxu-scss-dynamic 还是采用按需引入elememnt的方式

src/styles/index.scss 这里全量引入暗黑模式的样式

// import dark theme
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;

// :root {
//   --ep-color-primary: red;
// }

body {
  font-family:
    Inter, system-ui, Avenir, 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
    'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  margin: 0;
}

a {
  color: var(--ep-color-primary);
}

main.ts在main.ts中引入样式

// import './assets/main.css'

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import '@/styles/index.scss'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')

src/styles/element/index.scss 这里是自定义默认主题变量

$--colors: (
  'primary': (
    'base': rgb(0, 128, 19),
  ),
  'success': (
    'base': #21ba45,
  ),
  'warning': (
    'base': #f2711c,
  ),
  'danger': (
    'base': #db2828,
  ),
  'error': (
    'base': #db2828,
  ),
  'info': (
    'base': #42b8dd,
  ),
);

// You should use them in scss, because we calculate it by sass.
// comment next lines to use default color
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  // do not use same name, it will override.
  $colors: $--colors // $button-padding-horizontal: ('default': 50px)
);

// if you want to import all
// @use "element-plus/theme-chalk/src/index.scss" as *;

// You can comment it to hide debug info.
// @debug $--colors;

// custom dark variables
@use './dark.scss';

src/styles/element/dark.scss 这是对暗黑主题颜色重新定义

// only scss variables

$--colors: (
  'primary': (
    'base': #589ef8,
  ),
);

@forward 'element-plus/theme-chalk/src/dark/var.scss' with (
  $colors: $--colors
);

vite.config.ts 这里引入自定义主题变量,自定义默认主题和暗黑主题颜色

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import ElementPlus from 'unplugin-element-plus/vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueDevTools(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [
        ElementPlusResolver({
          importStyle: 'sass',
        }),
      ],
    }),
    ElementPlus({
      useSource: true,
    }),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "@/styles/element/index.scss" as *;`,
      },
    },
  },
})

动态切换主题

  • 通过useDark获取当前是否是暗黑模式
  • 通过useToggle切换主题
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { useDark, useToggle } from '@vueuse/core'
const isDark = useDark()
const toggleDark = useToggle(isDark)
const changeTheme = () => {
  toggleDark()
}
</script>

<template>
  <header>
    <el-button type="primary" @click="changeTheme"> 切换主题 </el-button>
    <el-input></el-input>
  </header>

  <RouterView />
</template>

<style scoped>
header {
  line-height: 1.5;
  max-height: 100vh;
}

.logo {
  display: block;
  margin: 0 auto 2rem;
}

nav {
  width: 100%;
  font-size: 12px;
  text-align: center;
  margin-top: 2rem;
}

nav a.router-link-exact-active {
  color: var(--color-text);
}

nav a.router-link-exact-active:hover {
  background-color: transparent;
}

nav a {
  display: inline-block;
  padding: 0 1rem;
  border-left: 1px solid var(--color-border);
}

nav a:first-of-type {
  border: 0;
}

@media (min-width: 1024px) {
  header {
    display: flex;
    place-items: center;
    padding-right: calc(var(--section-gap) / 2);
  }

  .logo {
    margin: 0 2rem 0 0;
  }

  header .wrapper {
    display: flex;
    place-items: flex-start;
    flex-wrap: wrap;
  }

  nav {
    text-align: left;
    margin-left: -1rem;
    font-size: 1rem;

    padding: 1rem 0;
    margin-top: 1rem;
  }
}
</style>

代码仓库地址 github.com/Stacey1018/…

Next.js第七章(路由组)

作者 小满zs
2025年11月18日 13:25

路由组

路由组也是一种基于文件夹的约定范式,可以让我们开发者,按类别或者团队组织路由模块,并且不影响 URL 路径。

用法:只需要通过(groupName)包裹住文件夹名即可,例如(shop)(user)等,名字可以自定义。

image.png

定义多个根布局

这种一般是大型项目使用的,例如我们需要把,后台管理系统前台的门户网站,放到一个项目就可以使用这种方法实现。

image.png

使用方法:

  1. 先把app目录下的layout.tsx 文件删除
  2. 在每组的目录下创建layout.tsx文件,并且定义html,body标签。

image.png

Next.js第六章(平行路由)

作者 小满zs
2025年11月18日 13:23

平行路由

平行路由指的是在同一布局layout.tsx中,可以同时渲染多个页面,例如teamanalytics等,这个东西跟vuerouter-view类似。

image.png

基本用法

平行路由的使用方法就是通过@ + 文件夹名来定义,例如@team@analytics等,名字可以自定义。

平行路由也不会影响URL路径。

image.png

定义完成之后,我们就可以在layout.tsx中使用teamanalytics来渲染对应的页面,他会自动注入layout的props里面

注意:例子中我们使用了解构的语法,这里面的名称team,analytics需跟文件夹名称一致。

export default function RootLayout({children,team,analytics}: 
{children: React.ReactNode,team: React.ReactNode,analytics: React.ReactNode}
) {
    return (
        <html>
            <body>
                {team}
                {children}
                {analytics}
            </body>
        </html>
    )
}

独立路由

当我们使用了平行路由之后,我们为其单独定义loading,error,等组件使其拥有独立加载和错误处理的能力。

image.png

image.png

default.tsx

首先我们先认识一下子导航,每一个平行路由下面还可以接着创建对应的路由,例如@team下面可以接着创建@team/setting@team/user等。

那我们的目录结构就是:

├── @team
│   ├── page.tsx
│   ├── setting
│   │   └── page.tsx
└── @analytics
│    └── page.tsx
└── layout.tsx   
└── page.tsx

然后我们使用Link组件跳转子导航setting页面

import Link from "next/link"
export default function RootLayout({children,team,analytics}: 
{children: React.ReactNode,team: React.ReactNode,analytics: React.ReactNode}) {
    return (
        <html>
            <body>
                {team}
                {children}
                {analytics}
                <Link className="text-blue-500 block" href="/setting">Setting</Link>
            </body>
        </html>
    )
}

2.gif

观察上图我们发现,子导航使用Link组件跳转setting页面时,是没有问题的,但是我们在跳转之后刷新页面,就出现404了,这是怎么回事?

  • 当使用软导航Link组件跳转子页面的时候,这时候@analyticschildren 依然保持活跃,所以他只会替代@team里面的内容。
  • 而当我们使用硬导航浏览器页面刷新,此时@analyticschildren 已经失活,因为它的底层原理其实是同时匹配@team@analyticschildren 目录下面的setting 页面,但是只有@team 有这个页面,其他两个没有,所以导致404

解决方案:使用default.tsx来进行兜底,确保不会404

  • @analytics/default.tsx 定义default.tsx文件
  • app/default.tsx 定义default.tsx文件

3.gif

Tiptap 深度教程(四):终极定制 - 从零创建你的专属扩展

作者 泯泷
2025年11月18日 12:54

引言

欢迎来到《Tiptap 深度教程》系列的第四篇,也是最具深度的一章。在前几篇教程中,我们探索了如何利用 Tiptap 强大的开箱即用功能和丰富的扩展生态来快速构建编辑器。然而,Tiptap 的真正威力并不仅限于此,它最核心的优势在于其近乎无限的可扩展性。当标准功能无法满足你独特的产品需求时,你需要的能力,是创造

本篇教程将是一次深入 Tiptap 核心的旅程。我们将不再仅仅是 Tiptap 功能的"消费者",而是成为其功能的"创造者"。我们将一起揭开 Tiptap 扩展系统背后的神秘面纱,赋予你从零开始构建任何可以想象到的编辑器功能的能力。

为什么需要自定义扩展?

在实际项目开发中,你可能会遇到这些场景,而官方扩展无法完全满足:

  • 🎨 产品特色需求:需要独特的"警告框"、"提示卡片"等品牌化组件,体现产品个性
  • 💼 业务逻辑集成:评论系统的 @提及、文档协作的批注功能、工单系统的状态标签
  • 🏥 行业特殊需求:法律文档的条款自动编号、医疗记录的结构化字段、教育平台的互动题目
  • ⚡ 性能极致优化:为特定场景定制轻量级扩展,移除不必要的功能,优化包体积
  • 🔧 深度定制交互:实现符合用户习惯的特殊编辑行为,如特定的快捷键、自动补全逻辑

当你遇到这些情况时,自定义扩展就是你的"超级武器"。

📋 本章学习目标

完成本章学习后,你将能够:

理解扩展本质:深入掌握 Node、Mark、Extension 三种扩展类型的底层原理和使用场景 ✅ 创建自定义 Node:从零构建块级节点(如 Callout 提示框),掌握文档结构定制 ✅ 创建自定义 Mark:实现行内标记(如彩色高亮),掌握文本格式扩展 ✅ 掌握高级 API:灵活运用命令、输入规则、Storage 等高级能力 ✅ 构建复杂扩展:完成生产级 Mention 扩展的完整实现,整合所有知识点

学习路径

在这趟旅程中,我们将遵循一条从理论到实践,从基础到专家的学习路径:

  • 🔍 探究底层原理:深入剖析 Tiptap 与其底层引擎 ProseMirror 之间的关系,为你建立坚实的理论基础

  • ✍️ 实践创造:逐行代码,从无到有地创建自定义的 Node(节点)和 Mark(标记),亲手体验扩展开发的全过程

  • 🚀 掌握高级 API:学习如何通过命令(Commands)、输入规则(Input Rules)和状态管理(Storage)等高级 API,为扩展注入强大的交互能力

  • 🏗️ 构建终极案例:将所学知识融会贯通,构建一个生产级别的、完全交互式的 @mention(提及)扩展

准备好迎接挑战,开启你的 Tiptap 大师之路!

第一节:Tiptap 扩展的解剖学:超越基础

在动手编写代码之前,我们必须建立一个清晰且准确的心智模型。理解 Tiptap 扩展的本质、其与底层引擎 ProseMirror 的关系,以及不同类型扩展的职责划分,是进行高级定制的前提。

ProseMirror 的连接:Tiptap 的引擎室

要真正理解 Tiptap,就必须认识到它是一个“无头(headless)”的编辑器框架,它本身并不提供用户界面,而是专注于编辑器逻辑。其强大的功能构建在一个名为 ProseMirror 的工具集之上。你可以将 ProseMirror 想象成一个高性能的汽车引擎,而 Tiptap 则是围绕这个引擎精心设计的底盘、传动系统和一套对开发者更友好的驾驶舱(API)。

Tiptap 巧妙地封装了 ProseMirror 的复杂性,提供了更易于理解和使用的 API。然而,当我们需要进行深度定制时,仅仅了解 Tiptap 的 API 是不够的。我们必须深入引擎室,理解 ProseMirror 的核心概念,例如:

  • Schema(模式):这是文档的“语法规则”,定义了哪些类型的内容(节点和标记)是合法的,以及它们之间如何嵌套。创建自定义

    NodeMark 的本质,就是在修改这个 Schema。

  • State(状态):一个不可变的(immutable)对象,包含了编辑器的所有信息,包括文档内容、当前选区、激活的标记等。编辑器的每一次变更都会产生一个新的 State。

  • Plugins(插件):它们是 ProseMirror 的“事件监听器”和“行为干预器”,可以观察并响应编辑器的各种变化,实现如协同编辑、输入快捷方式等复杂功能 8。

Tiptap 的设计哲学可以看作是一种“渐进式披露”。对于常规需求,你只需使用 Tiptap 的高层 API。但当你需要极致的控制力时,Tiptap 会为你打开通往底层 ProseMirror 的大门。本教程也将遵循这一哲学,从 Tiptap 的便捷 API 开始,逐步深入到更强大的 ProseMirror 概念中。

Node、Mark 和 Extension:职责明确的三驾马车

Tiptap 的一切皆为扩展,但根据其核心职责,我们可以将其分为三种基本类型 10。理解它们的区别至关重要,因为它决定了你在实现特定功能时应该选择哪种类型的扩展。

  • Nodes(节点):它们是构成文档结构的“积木” 11。想象一下,一篇文章由标题、段落、图片、代码块等组成,这些都是节点。节点可以是块级元素(

    block),如段落(Paragraph);也可以是行内元素(inline),如表情符号(Emoji)或图片(Image)12。它们是文档内容的承载者。

  • Marks(标记):它们用于为节点内的文本添加“行内样式”或“元数据”,而不会改变文档的结构 14。例如,将一段文字加粗(

    Bold)、设置为斜体(Italic)或添加超链接(Link),这些都是通过 Mark 实现的 13。Mark 就像是给文字涂上的高光,它依附于文字,但文字本身依然在段落(Node)中。

  • Generic Extensions(通用扩展):这类扩展不直接向文档的 Schema 中添加新的内容类型。它们的职责是增强编辑器的功能或行为 10。例如,

    TextAlign 扩展通过添加命令和属性来控制文本对齐,但它并没有创造一个新的“居中段落”节点 10。其他例子还包括监听编辑器更新事件(

    onUpdate)、添加全局键盘快捷键或集成复杂的 ProseMirror 插件。

为了更清晰地理解这三者的区别,下表提供了一个快速参考:

类型 (Type) 主要目的 (Primary Purpose) 对 Schema 的影响 (Impact on Schema) 常见示例 (Common Examples)
Node 定义文档的结构性内容块。 修改 Schema,添加新的内容类型。 Paragraph, Heading, Image, CodeBlock
Mark 为文本添加行内格式或元数据。 修改 Schema,添加新的格式类型。 Bold, Italic, Link, Highlight
Extension 增强编辑器功能、行为和交互。 不修改 Schema History (undo/redo), Placeholder, CharacterCount

这个表格清晰地揭示了一个核心原则:Schema 为王。当你需要定义一种新的内容类型时,你必须选择 NodeMark。当你只需要添加行为逻辑时,Extension 则是正确的选择。这个看似简单的区分,是构建健壮、可维护的自定义扩展的基石。

扩展 API:核心 Schema 定义

无论是创建哪种类型的扩展,我们都将从 Tiptap 提供的 create 方法开始,例如 Node.create({})Mark.create({}) 。在这个核心对象中,有几个属性是定义 Schema 的关键:

  • name: 扩展的唯一标识符,必须是字符串。这个名字至关重要,后续的命令调用、状态存储访问都将依赖它 11。

  • group: 定义了该节点所属的类别,例如 'block''inline' 8。这个属性直接影响到 ProseMirror 的内容表达式如何解析该节点,决定了它能出现在文档的什么位置 6。

  • content: 仅用于 Node 类型,这是一个“内容表达式”字符串,定义了该节点可以包含哪些子节点。例如,'inline*' 表示可以包含零个或多个行内节点,而 'paragraph+' 表示必须包含至少一个段落节点 5。这是 ProseMirror Schema 规则的直接体现,也是保证文档结构合法性的关键 19。

  • parseHTML: 定义了如何将一段 HTML 代码解析成当前扩展所代表的节点或标记。当用户粘贴内容或从数据库加载 HTML 时,这个函数会被调用,它就像是“输入转换器” 。

  • renderHTML: 定义了如何将编辑器内部状态中的节点或标记渲染成 HTML。当你需要保存文档内容或在只读模式下显示时,这个函数会被调用,它就像是“输出转换器” 。

掌握了这些基础概念,我们就拥有了与 Tiptap 核心对话的语言。接下来,我们将通过亲手实践,将这些理论知识转化为具体的、功能强大的自定义扩展。

第二节:从零到 Node:构建一个自定义“Callout”块

理论知识是基础,但真正的掌握源于实践。在本章中,我们将一步步地创建一个功能完整的自定义块级 Node——一个“Callout”组件。这种组件在文档中非常常见,用于高亮显示提示、警告或重要信息。通过这个例子,我们将把上一章的概念付诸实践。

步骤一:搭建 Node 的骨架

万丈高楼平地起。我们首先要用 Node.create 方法定义 Callout 节点的基本结构 10。创建一个新文件

Callout.js

import { Node, mergeAttributes } from '@tiptap/core';

export const Callout = Node.create({
  name: 'callout', // 1. 唯一名称
  
  group: 'block', // 2. 属于块级节点组

  content: 'paragraph+', // 3. 内容必须是至少一个段落

  defining: true, // 4. 这是一个定义边界的节点

  //... 更多配置将在这里添加
});

让我们来解析这段骨架代码:

  1. name: 'callout': 为我们的节点提供一个全局唯一的名称 11。

  2. group: 'block': 声明这是一个块级元素,它会独占一行,不能和普通文本混排 11。

  3. content: 'paragraph+': 这是对节点内容最核心的约束。它规定了 Callout 内部必须包含一个或多个段落(paragraph)节点 6。这确保了 Callout 内部内容的结构规范,避免了直接在其中放置裸露文本或其他不合规的块级节点。

  4. defining: true: 这是一个非常重要的属性。它告诉编辑器,这个节点是一个独立的“定义单元”。这意味着用户的光标无法部分选中 Callout 的内容和外部内容,也无法轻易地通过按回车或删除键将其与其他节点合并或拆分。这对于保持 Callout 结构的完整性至关重要。

步骤二:序列化(HTML 与编辑器状态的桥梁)

现在我们有了节点的内部定义,但 Tiptap 还不知道如何将它显示为 HTML,也不知道如何从 HTML 中识别它。这就是 renderHTMLparseHTML 的工作。

2.1 最简单的渲染实现

让我们从最基础的版本开始:

// 在 Callout.js 的 Node.create({}) 内部添加

renderHTML() {
  // 最简版本:只渲染一个 div 标签
  // 0 表示子内容的插入位置
  return ['div', {}, 0];
},

2.2 添加类型标识

为了让 HTML 更具语义化,我们添加一个 data-type 属性来标识这是 Callout 节点:

renderHTML() {
  // 添加 data-type 属性标识节点类型
  return ['div', { 'data-type': 'callout' }, 0];
},

2.3 完整版本:合并属性

最终版本需要能够接收并合并动态属性(后面会用到):

renderHTML({ HTMLAttributes }) {
  // 使用 mergeAttributes 合并默认属性和传入的属性
  return [
    'div',
    mergeAttributes(HTMLAttributes, { 'data-type': 'callout' }),
    0  // 子内容渲染位置
  ];
},

💡 渲染数组格式说明

  • 第一个元素:HTML 标签名('div'
  • 第二个元素:标签属性对象
  • 第三个元素:0 是特殊占位符,表示子内容应该被渲染到这里

2.4 配置解析规则

现在添加相反的逻辑——如何从 HTML 识别 Callout 节点:

parseHTML() {
  return [
    {
      tag: 'div[data-type="callout"]', // 匹配带有特定属性的 div 标签
    },
  ];
},

工作原理

  • renderHTML:编辑器状态 → HTML(用于保存和显示)
  • parseHTML:HTML → 编辑器状态(用于加载和粘贴)

步骤三:添加动态属性

一个静态的 Callout 不够灵活。我们希望能够创建不同类型的 Callout,比如“提示(info)”、“警告(warning)”和“危险(danger)”,并通过 CSS 为它们应用不同的样式。这需要用到属性(Attributes)。

添加属性是一个闭环操作,需要三步:定义、渲染和解析。这体现了属性数据的双向流动性:从编辑器状态到 HTML,再从 HTML 回到编辑器状态。遗漏任何一环都会导致数据在保存或加载时丢失。

  1. 定义属性:使用 addAttributes 方法。

    // 在 Node.create({}) 内部添加
    
    addAttributes() {
      return {
        calloutType: {
          default: 'info', // 默认类型是 'info'
        },
      };
    },
    

    这里我们定义了一个名为 calloutType 的属性,并为其设置了默认值 'info' 5。

  2. 渲染属性:修改 renderHTML,将属性值写入 DOM。

    // 修改 renderHTML 方法
    
    renderHTML({ HTMLAttributes }) {
      // HTMLAttributes 中会自动包含 calloutType
      return [
        'div',
        mergeAttributes(HTMLAttributes, { 'data-type': 'callout' }),
        0
      ];
    },
    

    Tiptap 会自动将 addAttributes 中定义的属性(如 calloutType)映射到 HTMLAttributes 对象中,并以 data- 前缀的形式渲染到 HTML 标签上。最终生成的 HTML 会是 <div data-type="callout" data-callout-type="info">...</div>

  3. 解析属性:修改 parseHTML,从 DOM 中读取属性值。

    // 修改 parseHTML 方法
    
    parseHTML() {
      return [
        {
          tag: 'div[data-type="callout"]',
          getAttrs: (element) => ({
            calloutType: element.getAttribute('data-callout-type'),
          }),
        },
      ];
    },
    

    我们在解析规则中添加了 getAttrs 函数。当匹配到 div 标签时,此函数会执行,读取 data-callout-type 属性的值,并将其赋值给我们节点状态中的 calloutType 属性。

现在,我们的 Callout 节点已经具备了动态样式的能力。你可以通过 CSS 选择器 div[data-callout-type="warning"] 来为其定义独特的样式。

步骤四:创建命令

如果用户只能通过手动编写 HTML 来创建 Callout,那体验就太糟糕了。我们需要提供编程式的接口——命令(Commands),以便通过按钮或其他 UI 元素来操作 Callout 扩展。

// 在 Node.create({}) 内部添加
// 别忘了在文件顶部引入 declare module '@tiptap/core'

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    callout: {
      /**
       * 设置或切换 Callout 块
       */
      toggleCallout: (attributes: { calloutType: string }) => ReturnType,
    }
  }
}

//...

addCommands() {
  return {
    toggleCallout: (attributes) => ({ commands }) => {
      // 使用 toggleBlock 来在段落和 Callout 之间切换
      return commands.toggleBlock(this.name, 'paragraph', attributes);
    },
  };
},

我们使用 addCommands 方法来定义命令。这里我们创建了一个 toggleCallout 命令。我们巧妙地利用了 Tiptap 内置的 toggleBlock 命令,它可以智能地在两种块类型之间切换。如果当前选区是段落,它会将其转换为 callout;如果已经是 callout,则会将其转换回段落。我们还通过 attributes 参数,允许在创建 Callout 时动态指定其类型。

通过 TypeScript 的 declare module,我们将自定义命令注入到了 Tiptap 的全局命令接口中,这能为我们带来极佳的类型提示和自动补全体验。

步骤五:集成到编辑器与样式定制

5.1 集成扩展

最后一步,将我们精心打造的 Callout 扩展集成到 Tiptap 编辑器实例中:

// 在你的编辑器配置文件中
import { Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { Callout } from './Callout.js'; // 引入我们的扩展

const editor = new Editor({
  extensions: [
    StarterKit,
    Callout,  // 添加 Callout 扩展
  ],
  //... 其他配置
});

5.2 完整的 CSS 样式

现在让我们为 Callout 添加美观且实用的样式,实现不同类型的视觉效果:

/* Callout 基础样式 */
.tiptap div[data-type="callout"] {
  padding: 1rem;
  border-radius: 0.5rem;
  margin: 1rem 0;
  border-left: 4px solid;
  background-color: #f8fafc;
  transition: all 0.2s ease;
}

/* Info 类型 - 蓝色主题 */
.tiptap div[data-callout-type="info"] {
  background-color: #eff6ff;
  border-left-color: #3b82f6;
}

.tiptap div[data-callout-type="info"]::before {
  content: 'ℹ️ 提示';
  display: block;
  font-weight: 600;
  color: #1e40af;
  margin-bottom: 0.5rem;
}

/* Warning 类型 - 黄色主题 */
.tiptap div[data-callout-type="warning"] {
  background-color: #fefce8;
  border-left-color: #eab308;
}

.tiptap div[data-callout-type="warning"]::before {
  content: '⚠️ 警告';
  display: block;
  font-weight: 600;
  color: #a16207;
  margin-bottom: 0.5rem;
}

/* Danger 类型 - 红色主题 */
.tiptap div[data-callout-type="danger"] {
  background-color: #fef2f2;
  border-left-color: #ef4444;
}

.tiptap div[data-callout-type="danger"]::before {
  content: '🚨 危险';
  display: block;
  font-weight: 600;
  color: #b91c1c;
  margin-bottom: 0.5rem;
}

/* Success 类型 - 绿色主题 */
.tiptap div[data-callout-type="success"] {
  background-color: #f0fdf4;
  border-left-color: #22c55e;
}

.tiptap div[data-callout-type="success"]::before {
  content: '✅ 成功';
  display: block;
  font-weight: 600;
  color: #15803d;
  margin-bottom: 0.5rem;
}

/* Callout 内部段落样式 */
.tiptap div[data-type="callout"] p {
  margin: 0;
  line-height: 1.6;
}

.tiptap div[data-type="callout"] p + p {
  margin-top: 0.5rem;
}

5.3 使用示例

现在,你就可以在编辑器 UI 中添加一个按钮,点击时调用命令:

// 在你的工具栏组件中
<button
  onClick={() => editor.commands.toggleCallout({ calloutType: 'warning' })}
  className={editor.isActive('callout', { calloutType: 'warning' }) ? 'is-active' : ''}
>
  ⚠️ 警告框
</button>

<button
  onClick={() => editor.commands.toggleCallout({ calloutType: 'info' })}
  className={editor.isActive('callout', { calloutType: 'info' }) ? 'is-active' : ''}
>
  ℹ️ 提示框
</button>

5.4 功能验证清单

测试你的 Callout 扩展是否完整实现:

✅ 通过命令创建 Callout 块 ✅ 切换不同的 calloutType(info、warning、danger、success) ✅ 验证属性正确序列化到 HTML ✅ 从 HTML 粘贴能正确解析为 Callout ✅ CSS 样式正确应用到不同类型 ✅ 在 Callout 内部可以正常编辑段落内容

🎉 恭喜! 你已经成功创建了第一个功能完整、样式精美的自定义节点扩展!

第三节:从零到 Mark:打造一个带颜色的自定义“高亮”

掌握了 Node 的创建之后,Mark 的创建就变得轻车熟路了。Mark 用于实现行内格式,如加粗、链接等。在本章中,我们将创建一个比 Tiptap 内置高亮更强大的版本:一个可以自定义高亮颜色的 coloredHighlight 标记。这个过程将巩固我们对属性和命令的理解,并引入新的概念,如键盘快捷键。

步骤一:搭建 Mark 的骨架

Node 类似,我们使用 Mark.create 方法开始 10。创建一个新文件

ColoredHighlight.js

import { Mark, mergeAttributes } from '@tiptap/core';

export const ColoredHighlight = Mark.create({
  name: 'coloredHighlight', // 1. 唯一名称

  spanning: false, // 2. 默认情况下,标记不能跨越块级节点

  //... 更多配置
});
  1. name: 'coloredHighlight': 同样,一个唯一的名称是必不可少的。

  2. spanning: false: 这个属性默认为 false,意味着标记不能跨越不同的块级节点。例如,如果用户选中了两个段落的部分文本,应用此标记后,它会分别在两个段落内生效,而不会形成一个单一的、跨越段落边界的标记。在大多数情况下,这是我们期望的行为 15。

步骤二:序列化与属性

我们的核心需求是能够自定义颜色。这自然需要一个 color 属性。与 Node 一样,我们需要完成定义、渲染、解析三部曲。

2.1 配置选项(Options)

首先,我们需要为扩展添加配置选项,定义默认颜色:

// 在 Mark.create({}) 内部添加

addOptions() {
  return {
    color: '#FFFF00',  // 默认颜色为黄色
  }
},

💡 Options vs Attributes

  • Options:扩展级别的配置,在扩展实例化时设置,影响所有该扩展的实例
  • Attributes:节点级别的数据,每个节点实例可以有不同的值

2.2 定义属性

addAttributes() {
  return {
    color: {
      default: this.options.color,  // 使用 options 中的默认颜色
      parseHTML: element => element.style.backgroundColor || '',
      renderHTML: attributes => {
        if (!attributes.color) {
          return {};
        }
        return {
          style: `background-color: ${attributes.color}`,
        };
      },
    },
  };
},

2.3 配置序列化

parseHTML() {
  return [
    {
      tag: 'mark',  // 匹配 <mark> 标签
      getAttrs: (element) => {
        // 从 style 属性中解析背景颜色
        const color = element.style.backgroundColor;
        return color ? { color } : {};
      },
    },
  ];
},

renderHTML({ HTMLAttributes }) {
  // mergeAttributes 会自动处理 color 属性转为 style
  return ['mark', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},

工作原理解析

  • addOptions:定义扩展级别的默认配置
  • addAttributes:定义节点级别的动态数据,引用 options
  • parseHTML:从 HTML 的 style 提取背景色
  • renderHTML:将 color 属性渲染为内联 style

步骤三:创建一套完整的命令

一个优秀的扩展应该提供一个完整、可预测的编程接口(API),方便 UI 调用。这不仅仅是"让按钮工作",而是精心设计扩展的外部交互方式。对于高亮标记,我们需要设置、切换和取消三种操作 14。

// 在 ColoredHighlight.js 中

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    coloredHighlight: {
      /**
       * 设置高亮并指定颜色
       */
      setHighlight: (attributes: { color: string }) => ReturnType,
      /**
       * 切换高亮状态
       */
      toggleHighlight: (attributes: { color: string }) => ReturnType,
      /**
       * 取消高亮
       */
      unsetHighlight: () => ReturnType,
    }
  }
}

// 在 Mark.create({}) 内部添加
addCommands() {
  return {
    setHighlight: (attributes) => ({ commands }) => {
      return commands.setMark(this.name, attributes);
    },
    toggleHighlight: (attributes) => ({ commands }) => {
      return commands.toggleMark(this.name, attributes);
    },
    unsetHighlight: () => ({ commands }) => {
      return commands.unsetMark(this.name);
    },
  };
},

我们再次使用了 Tiptap 内置的命令助手:setMarktoggleMarkunsetMark。它们极大地简化了逻辑。通过提供这一整套命令,我们让 UI 层的开发变得异常简单:

  • 颜色选择器可以选择一个颜色,然后调用 editor.commands.setHighlight({ color: '#FFC0CB' })

  • 一个开关按钮可以调用 editor.commands.toggleHighlight({ color: '#FFFF00' })

  • 一个“清除格式”按钮可以调用 editor.commands.unsetHighlight()

通过 declare module 再次扩展 TypeScript 接口,我们确保了这套 API 是完全类型安全且具备自动补全的,极大地提升了开发体验 20。

步骤四:添加键盘快捷键

为了提升效率,我们可以为最常用的命令绑定键盘快捷键。addKeyboardShortcuts 方法让这一切变得简单。

// 在 Mark.create({}) 内部添加

addKeyboardShortcuts() {
  return {
    'Mod-Shift-H': () => this.editor.commands.toggleHighlight({ color: this.options.color }),
  };
},

这段代码将 Cmd+Shift+H (在 Mac 上) 或 Ctrl+Shift+H (在 Windows 上) 绑定到了 toggleHighlight 命令上 14。当用户按下快捷键时,它会使用我们在

addAttributes 中定义的默认颜色来切换高亮。

至此,我们的 coloredHighlight 扩展已经完成。它不仅能实现基本的文本高亮,还能自定义颜色,提供了一套完整的命令 API,并支持键盘快捷键。通过这个例子,我们进一步巩固了对 Tiptap 扩展核心概念的理解,并为进入更高级的主题做好了准备。

第四节:高级能力 - 让你的扩展活起来

我们已经掌握了如何定义扩展的“骨骼”(Schema)和“肌肉”(Commands)。现在,是时候为它们注入“神经系统”了。本章将探索 Tiptap 提供的高级 API,它们能让你的扩展具备动态行为、状态管理和智能自动化能力,从而极大地提升用户体验。

使用输入和粘贴规则实现自动化

addInputRulesaddPasteRules 是两个极为强大的 UX 增强工具。它们允许扩展监听用户的输入和粘贴行为,并根据预设的模式自动触发相应的操作,例如实现流行的 Markdown 快捷语法。

  • addInputRules:实时输入转换

    输入规则会在用户键入时实时匹配文本模式。我们将为第二章创建的 Callout 节点添加一个输入规则:当用户在新的一行输入 >> (大于号加空格) 时,自动将该段落转换为一个 Callout 块。

    // 在 Callout.js 的 Node.create({}) 内部添加
    import { nodeInputRule } from '@tiptap/core';
    
    //...
    addInputRules() {
      return [
        nodeInputRule({
          find: /^>>\s$/,
          type: this.type,
        }),
      ];
    },
    

    我们使用了 Tiptap 提供的 nodeInputRule 帮助函数。它接收一个配置对象,

    find 属性是一个正则表达式,用于匹配触发模式;type 属性则指定了匹配成功后要创建的节点类型,this.type 在这里就指向 Callout 节点本身。现在,用户无需点击任何按钮,只需输入简单的快捷符,就能创建 Callout,效率大增。

  • addPasteRules:智能粘贴处理

    粘贴规则与输入规则类似,但它作用于用户粘贴内容时。我们将为第三章的 coloredHighlight 标记添加一个粘贴规则:当用户粘贴形如 ==被高亮的文本== 的内容时,自动为其应用高亮标记。

    // 在 ColoredHighlight.js 的 Mark.create({}) 内部添加
    import { markPasteRule } from '@tiptap/core';
    
    //...
    addPasteRules() {
      return [
        markPasteRule({
          find: /==(.*?)==/g,
          type: this.type,
        }),
      ];
    },
    

    这里我们使用了 markPasteRule 帮助函数 22。

    find 正则表达式中的 g (global) 标志至关重要,它确保了如果粘贴的内容中有多处匹配,规则会对每一处都生效 22。这个小小的功能,使得从其他支持类似 Markdown 语法的应用(如 Obsidian, Notion)中复制内容到我们的编辑器时,格式能够被无缝保留。

使用 addStorage 管理内部状态

在开发复杂扩展时,我们经常需要存储一些数据。Tiptap 提供了两种状态存储机制:addAttributesaddStorage。理解它们的区别是设计高级扩展的关键。

这是一个关于状态二元性的核心概念:文档状态 vs. 运行时状态

  • addAttributes 用于存储 文档状态。这些数据是文档内容的一部分,需要被序列化(保存到 HTML 或 JSON),并在加载时恢复。例如,一个链接的 href 地址,或者我们 Callout 的 calloutType。这些数据必须是可序列化为 JSON 的简单值。

  • addStorage 用于存储 运行时状态。这些数据只存在于当前编辑器实例的生命周期中,不会被保存到文档内容里 17。它可以是任何类型的数据,比如一个函数的引用、一个复杂的对象、一个计时器 ID,或者用于分析的计数器。

让我们创建一个简单的扩展来演示 addStorage 的用法。这个扩展将统计编辑器内容被更新了多少次。

import { Extension } from '@tiptap/core';

// 为存储添加 TypeScript 类型,增强代码健壮性
declare module '@tiptap/core' {
  interface ExtensionStorage {
    updateCounter: {
      count: number,
    }
  }
}

export const UpdateCounter = Extension.create({
  name: 'updateCounter',

  addStorage() {
    return {
      count: 0, // 初始化存储
    };
  },

  onUpdate() {
    this.storage.count += 1; // 在每次更新时修改存储
    console.log('Editor updated', this.storage.count, 'times.');
  },
});

在这个例子中:

  1. 我们使用 addStorage 返回一个对象,作为这个扩展的初始状态 18。

  2. onUpdate 生命周期钩子中,我们通过 this.storage 访问并修改这个状态 18。

  3. 这个 count 值是临时的,刷新页面后就会重置。

我们也可以从扩展外部访问这个存储,只需通过 editor.storage.extensionName 18:

const count = editor.storage.updateCounter.count;

通过 declare module 为存储定义类型,可以让我们在访问 editor.storage.updateCounter 时获得完整的 TypeScript 类型支持,避免拼写错误和类型滥用 20。

扩展的生命周期与副作用

Tiptap 扩展拥有一套丰富的生命周期钩子(Lifecycle Hooks),允许我们在编辑器的关键时刻执行代码,处理副作用。

常用的钩子包括:

  • onCreate: 编辑器实例创建并准备就绪时触发。

  • onUpdate: 编辑器内容发生变化时触发。

  • onSelectionUpdate: 编辑器选区变化时触发。

  • onTransaction: 每一次状态变更(Transaction)发生时触发。这是最底层的变化监听。

  • onFocus / onBlur: 编辑器获得或失去焦点时触发。

  • onDestroy: 编辑器实例被销毁前触发,适合用于清理工作。

重要陷阱:生命周期钩子中的无限循环

一个常见的错误是在 onUpdate 或 onTransaction 钩子中直接调用 editor.commands 来修改编辑器状态。这会导致一个新的更新事件,从而再次触发钩子,形成一个无限循环,最终导致浏览器崩溃 8。

错误的做法

onUpdate({ editor }) {
  // 危险!这会造成无限循环!
  editor.commands.setNode('paragraph');
}

正确的做法:在事务(Transaction)层面思考

这些钩子通常会提供一个 transaction 对象(简写为 tr)。如果你确实需要在这些钩子中修改状态,你应该直接操作这个 tr 对象,而不是派发一个新的命令。ProseMirror 会将这些修改合并到当前的事务中,从而避免了循环。

onTransaction({ transaction }) {
  if (someCondition) {
    // 安全的做法:直接修改当前事务
    transaction.setNodeMarkup(...);
  }
}

虽然直接操作 tr 属于更高级的 ProseMirror API,但理解这个原则至关重要:生命周期钩子是用于“响应”变化的,而不是“创造”新的变化

为了帮助你快速查阅这些高级 API,下表总结了它们的核心用途:

方法 (Method) 目的 (Purpose) 用例 (Use Case Example)
addCommands 定义扩展的编程接口,供 UI 或其他逻辑调用。 toggleHighlight() 命令用于切换高亮。
addKeyboardShortcuts 绑定键盘快捷键到特定命令。 Mod-B 绑定到 toggleBold() 命令。
addInputRules 根据用户输入实时转换文本(Markdown 语法)。 输入 * 自动创建无序列表。
addPasteRules 根据粘贴的内容自动转换文本。 粘贴 (url) 自动创建链接。
addStorage 管理扩展的、非持久化的、运行时的内部状态。 存储一个 debounce 函数或用于分析的计数器。
addNodeView (高级) 使用框架组件(如 React/Vue)完全自定义节点的渲染和交互。 创建一个带可编辑标题和交互按钮的视频嵌入节点。
addProseMirrorPlugins (最高级) 注入底层的 ProseMirror 插件,以获得对编辑器行为的完全控制。 实现提及(Mention)功能的建议弹出框。

掌握了这些“超能力”,你就拥有了构建几乎任何复杂交互的工具。它们是 Tiptap 便捷 API 和底层 ProseMirror 强大功能之间的桥梁。在下一章,我们将把所有这些能力集于一身,挑战一个终极案例。

第五节:终极案例研究:构建一个交互式提及(Mention)扩展

现在,我们将踏上本次教程的顶峰。我们将综合运用前面所有章节学到的知识——Node 定义、属性、命令、Node ViewProseMirror 插件——来构建一个功能完整、高度交互、生产级别的 @mention(提及或标签)扩展。

这个案例之所以是“终极”,因为它完美地展示了构建复杂 Tiptap 扩展所需的三位一体架构:

  1. 数据模型 (Node):定义“提及”在文档中如何存储。

  2. 视图渲染 (Node View):定义“提及”在编辑器中如何显示为一个漂亮的、不可编辑的“胶囊”UI。

  3. 交互逻辑 (ProseMirror Plugin):定义当用户输入 @ 时,如何触发、显示和处理建议列表的弹出框。

步骤一:架构设计

在动手之前,我们先规划好架构。我们的 Mention 扩展将由以下几个部分组成:

  1. Mention.js: 这是扩展的主文件,它将:

    • 使用 Node.create 定义 mention 节点的数据结构。

    • 使用 addNodeView 将节点的渲染委托给一个 React (或 Vue) 组件。

    • 使用 addProseMirrorPlugins 注入一个自定义插件来处理建议弹出框的逻辑。

  2. MentionComponent.jsx: 一个 React 组件,负责渲染“提及胶囊”的 UI。

  3. suggestion.js: 一个辅助文件,包含创建和管理建议弹出框(我们将使用(atomiks.github.io/tippyjs/) 库)的 ProseMirror 插件逻辑。

步骤二:构建 Mention 节点(数据模型)

首先,我们来定义 mention 节点本身。它是一个行内(inline)节点,用于在文本流中表示一个提及。

// Mention.js
import { Node, mergeAttributes } from '@tiptap/core';

export const Mention = Node.create({
  name: 'mention',
  group: 'inline',
  inline: true,
  selectable: false,
  atom: true, // 关键!

  addAttributes() {
    return {
      id: {
        default: null,
        parseHTML: element => element.getAttribute('data-id'),
        renderHTML: attributes => {
          if (!attributes.id) {
            return {};
          }
          return { 'data-id': attributes.id };
        },
      },
      label: {
        default: null,
        parseHTML: element => element.getAttribute('data-label'),
        renderHTML: attributes => {
          if (!attributes.label) {
            return {};
          }
          return { 'data-label': attributes.label };
        },
      },
    };
  },

  parseHTML() {
    return [{ tag: 'span[data-type="mention"]' }];
  },

  renderHTML({ node, HTMLAttributes }) {
    return [
      'span',
      mergeAttributes({ 'data-type': 'mention' }, HTMLAttributes),
      `@${node.attrs.label}`
    ];
  },

  //... addNodeView 和 addProseMirrorPlugins 将在这里添加
});

这里的关键是 atom: true。这个属性告诉 ProseMirror,这个节点是一个不可分割的“原子”单元。用户不能将光标移动到它的内部,也不能编辑它的内容。ProseMirror 会将整个节点的管理权完全交给我们的 Node View 5。

addAttributes 定义了我们需要存储的数据:被提及用户的唯一 id 和显示的 labelrenderHTML 提供了一个简单的后备方案,用于在不支持 JavaScript 的环境中(如发送邮件)也能正确显示提及内容。

步骤三:使用 addNodeView 进行自定义渲染(视图渲染)

现在,我们要用一个交互式的 React 组件来取代 renderHTML 的静态渲染。这就是 addNodeView 的用武之地 5。

// 在 Mention.js 的 Node.create({}) 内部添加
import { ReactNodeViewRenderer } from '@tiptap/react';
import MentionComponent from './MentionComponent.jsx';

//...
addNodeView() {
  return ReactNodeViewRenderer(MentionComponent);
},

addNodeView 返回一个 ReactNodeViewRenderer(或 VueNodeViewRenderer),它将我们的 MentionComponent 组件与 mention 节点绑定起来 5。

现在,我们来创建 MentionComponent.jsx

// MentionComponent.jsx
import React from 'react';
import { NodeViewWrapper } from '@tiptap/react';

export default (props) => {
  return (
    <NodeViewWrapper as="span" className="mention">
      @{props.node.attrs.label}
    </NodeViewWrapper>
  );
};

这个组件非常简单。它使用了 Tiptap 提供的 NodeViewWrapper,它会渲染一个容器元素(我们指定为 <span>),并处理好所有 ProseMirror 需要的 DOM 属性和事件 5。组件通过

props.node.attrs 可以访问到我们在 addAttributes 中定义的 idlabel,从而渲染出我们想要的“胶囊”UI。你可以随意为 .mention 类添加 CSS 样式。

步骤四:使用 addProseMirrorPlugins 实现建议引擎(交互逻辑)

这是最核心、最复杂的部分。当用户输入 @ 时,我们需要一个弹出框来显示用户列表。Tiptap 的标准 API 无法直接实现这种复杂的、与 UI 紧密耦合的交互,因此我们必须深入底层,编写一个 ProseMirror 插件 8。

这是一个高度简化的实现思路,完整的代码会更长,但核心逻辑如下:

// suggestion.js (这是一个简化的逻辑概览)
import { Plugin, PluginKey } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import tippy from 'tippy.js';

export const suggestionPlugin = (options) => {
  return new Plugin({
    key: new PluginKey('mention_suggestion'),

    state: {
      init: () => ({ active: false, range: {}, query: '' }),
      apply: (tr, value) => {
        //... 在每次事务中,检查光标前的文本是否匹配触发符,如 /@(\w*)$/
        // 如果匹配,更新插件状态,记录 active=true, range 和 query
        // 如果不匹配,重置状态
        return newValue;
      },
    },

    view: (editorView) => {
      let popup;

      return {
        update: (view, prevState) => {
          const currentState = this.key.getState(view.state);
          const previousState = this.key.getState(prevState);

          // 如果状态从 inactive 变为 active,创建并显示 tippy 弹出框
          if (currentState.active &&!previousState.active) {
            // popup = tippy('body', {...配置... });
            // 在弹出框中渲染用户列表,列表数据可以根据 currentState.query 过滤
          }

          // 如果状态从 active 变为 inactive,销毁弹出框
          if (!currentState.active && previousState.active) {
            // popup.destroy();
          }
        },
        destroy: () => {
          // popup?.destroy();
        },
      };
    },
  });
};

这个插件的核心工作流程是:

  1. state.apply: 在每次编辑器状态更新时,检查光标前的文本。如果匹配 @ 触发符,就更新插件自己的内部状态,记录下触发的位置(range)和查询词(query)。

  2. view.update: 监听插件状态的变化。当状态变为“激活”时,它会创建一个 Tippy.js 弹出框,并根据查询词渲染建议列表。当状态变为“非激活”时,它会销毁弹出框。

  3. 命令交互: 在建议列表的 UI 中,当用户点击或回车选择一个用户时,UI 组件会调用一个 Tiptap 命令,例如 editor.commands.insertContent(...),用一个完整的 mention 节点替换掉触发文本(如 @john)。

最后,我们将这个插件集成到我们的 Mention.js 扩展中:

// 在 Mention.js 的 Node.create({}) 内部添加
import { suggestionPlugin } from './suggestion.js';

//...
addProseMirrorPlugins() {
  return [
    suggestionPlugin({
      editor: this.editor,
      //... 其他配置,如获取用户列表的函数
    }),
  ];
},

步骤五:最终集成

通过以上步骤,我们已经将数据模型(Node)、视图渲染(Node View)和交互逻辑(Plugin)这三个部分完美地结合在了一个单一的 Mention.js 扩展文件中。开发者在使用时,只需像注册任何其他扩展一样,将 Mention 添加到编辑器的 extensions 数组中,一个功能强大的提及系统就此诞生。

这个案例充分证明了 Tiptap 的分层设计思想。对于简单的需求,你可以使用高层 API;而对于像建议弹出框这样复杂的交互,Tiptap 也为你保留了通往底层 ProseMirror 的通道,让你拥有实现任何功能的终极自由。

💡 完整代码获取 Mention 扩展的完整实现代码较长,建议参考:

第六节:⚠️ 常见陷阱与调试技巧

在开发自定义扩展的过程中,你可能会遇到一些常见的问题。本节将帮助你快速识别和解决这些陷阱。

陷阱 1:生命周期钩子中的无限循环

问题现象: 浏览器卡死、内存占用飙升、控制台大量重复日志

错误示例

// ❌ 危险!会造成无限循环
onUpdate({ editor }) {
  editor.commands.setNode('paragraph');  // 这会触发新的 update
}

onTransaction({ transaction }) {
  this.editor.commands.insertContent('text');  // 同样会无限循环
}

正确做法

// ✅ 在事务层面思考,直接修改当前事务
onTransaction({ transaction }) {
  if (someCondition) {
    // 直接修改当前事务,不派发新命令
    transaction.setNodeMarkup(pos, type, attrs);
  }
}

// ✅ 或者添加条件检查避免重复触发
onUpdate({ editor }) {
  if (!editor.isActive('paragraph')) {
    editor.commands.setParagraph();
  }
}

⚠️ 核心原则:生命周期钩子用于"响应"变化,不是"创造"新变化

陷阱 2:属性序列化丢失

问题现象: 扩展的自定义属性在保存/加载后丢失

常见原因

// ❌ 只定义了 addAttributes,但没有配置 parseHTML 和 renderHTML
addAttributes() {
  return {
    customData: {
      default: null,
    },
  };
},

// 缺少这两个关键方法导致属性无法序列化
parseHTML() { ... }
renderHTML() { ... }

完整解决方案

// ✅ 完整的属性闭环:定义 → 渲染 → 解析
addAttributes() {
  return {
    customData: {
      default: null,
      // 2. 解析:从 HTML 读取
      parseHTML: element => element.getAttribute('data-custom'),
      // 3. 渲染:写入 HTML
      renderHTML: attributes => {
        if (!attributes.customData) return {};
        return { 'data-custom': attributes.customData };
      },
    },
  };
},

💡 记忆口诀:属性三步走 - 定义、渲染、解析,一个都不能少

陷阱 3:Schema 冲突导致的渲染错误

问题现象

  • 内容显示不正确
  • 某些节点无法创建
  • 控制台报 Schema 相关错误

常见原因

// ❌ content 表达式与实际内容不匹配
export const CustomBlock = Node.create({
  name: 'customBlock',
  content: 'paragraph+',  // 要求至少一个段落

  // 但 renderHTML 允许空内容
  renderHTML() {
    return ['div', { class: 'custom' }, 0];  // 0 允许任意内容
  },
})

解决方法

// ✅ 确保 content 定义与实际使用一致
export const CustomBlock = Node.create({
  name: 'customBlock',
  content: 'paragraph+',  // 明确要求段落

  // 创建时提供默认内容
  addCommands() {
    return {
      setCustomBlock: () => ({ commands }) => {
        return commands.insertContent({
          type: this.name,
          content: [
            { type: 'paragraph', content: [] }  // 提供默认段落
          ],
        });
      },
    };
  },
})

陷阱 4:命令执行顺序混乱

问题现象: 链式命令不按预期执行

错误示例

// ❌ focus() 应该在其他命令之前
editor.chain().toggleBold().focus().run();

正确做法

// ✅ focus() 放在链的开头
editor.chain().focus().toggleBold().run();

// ✅ 或者分步执行关键命令
editor.chain().focus().run();
editor.chain().toggleBold().run();

调试技巧与工具

1. 使用 ProseMirror DevTools

import { Editor } from '@tiptap/core';

const editor = new Editor({
  extensions: [/* ... */],
  // 开启开发者模式
  enableDebugMode: true,
})

// 在控制台访问编辑器状态
window.editor = editor;

// 查看当前文档结构
console.log(editor.state.doc.toJSON());

// 查看当前选区
console.log(editor.state.selection);

2. 监听关键事件

const DebugExtension = Extension.create({
  name: 'debugger',

  onCreate({ editor }) {
    console.log('📝 编辑器已创建', editor.getJSON());
  },

  onUpdate({ editor, transaction }) {
    console.log('🔄 内容更新', {
      docChanged: transaction.docChanged,
      steps: transaction.steps.length,
      content: editor.getJSON(),
    });
  },

  onSelectionUpdate({ editor }) {
    console.log('👆 选区变化', editor.state.selection.toJSON());
  },

  onTransaction({ transaction }) {
    if (transaction.steps.length > 0) {
      console.log('⚙️ 事务步骤', transaction.steps.map(s => s.toJSON()));
    }
  },
})

3. 验证扩展完整性检查清单

在发布自定义扩展前,使用此清单验证:

基础功能

  • 扩展名称唯一且语义化
  • Schema 定义完整(group、content、marks 等)
  • parseHTML 和 renderHTML 配对正确

属性管理

  • addAttributes 定义完整
  • 每个属性都有 parseHTML 和 renderHTML
  • 默认值设置合理

命令系统

  • 提供完整的命令集(set/toggle/unset)
  • TypeScript 声明完整
  • 命令可以正确执行和撤销

用户体验

  • 键盘快捷键不冲突
  • 输入规则不影响正常输入
  • 粘贴规则正确处理各种格式

性能与稳定性

  • 无内存泄漏(正确清理事件监听)
  • 无无限循环风险
  • 大文档下性能可接受

🔍 调试黄金法则:从简单到复杂,逐步排查。先验证 Schema,再检查属性,最后调试命令和交互。

本章核心成就

通过本章深入学习,你已经掌握了 Tiptap 扩展开发的完整技能树:

技能点 掌握程度 实际应用
扩展理论 ✅ 完成 理解 Node/Mark/Extension 本质区别与 ProseMirror 关系
自定义 Node ✅ 完成 创建 Callout 块级节点,掌握文档结构定制
自定义 Mark ✅ 完成 实现 ColoredHighlight 标记,掌握文本格式扩展
高级 API ✅ 完成 灵活运用命令、输入规则、Storage、生命周期钩子
复杂扩展 ✅ 完成 构建 Mention 交互系统,整合 NodeView 和 Plugin
问题排查 ✅ 完成 识别常见陷阱,掌握调试技巧和验证方法

🔑 核心概念速查表

扩展创建模板

// Node 创建模板
const CustomNode = Node.create({
  name: 'customNode',
  group: 'block',
  content: 'paragraph+',

  addAttributes() {
    return {
      attrName: {
        default: null,
        parseHTML: element => element.getAttribute('data-attr'),
        renderHTML: attributes => ({ 'data-attr': attributes.attrName }),
      },
    };
  },

  parseHTML() {
    return [{ tag: 'div[data-type="custom"]' }];
  },

  renderHTML({ HTMLAttributes }) {
    return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'custom' }), 0];
  },

  addCommands() {
    return {
      setCustomNode: (attributes) => ({ commands }) => {
        return commands.insertContent({ type: this.name, content: [{ type: 'paragraph' }] });
      },
    };
  },
})

// Mark 创建模板
const CustomMark = Mark.create({
  name: 'customMark',

  addOptions() {
    return {
      HTMLAttributes: {},
    };
  },

  addAttributes() {
    return {
      color: {
        default: null,
        parseHTML: element => element.style.color,
        renderHTML: attributes => ({ style: `color: ${attributes.color}` }),
      },
    };
  },

  parseHTML() {
    return [{ tag: 'span[data-custom-mark]' }];
  },

  renderHTML({ HTMLAttributes }) {
    return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
  },

  addCommands() {
    return {
      setCustomMark: (attributes) => ({ commands }) => {
        return commands.setMark(this.name, attributes);
      },
      toggleCustomMark: (attributes) => ({ commands }) => {
        return commands.toggleMark(this.name, attributes);
      },
      unsetCustomMark: () => ({ commands }) => {
        return commands.unsetMark(this.name);
      },
    };
  },
})

🚀 进阶学习路径

掌握了本章知识后,你可以探索以下高级主题:

  1. NodeView 深度定制

    • 使用 React/Vue 组件渲染复杂节点
    • 实现拖拽、调整大小等高级交互
    • 构建完全自定义的编辑体验
  2. Collaboration 协同编辑

    • 集成 Y.js 实现实时协作
    • 处理冲突解决和用户光标
    • 构建类似 Google Docs 的体验
  3. ProseMirror 插件系统

    • 深入理解 Plugin State
    • 自定义 DecorationSet 实现高亮
    • 构建复杂的编辑器交互逻辑
  4. 性能优化进阶

    • 虚拟滚动处理大文档
    • 懒加载和按需渲染策略
    • 节流和防抖优化编辑器响应

结论

我们已经走完了一段漫长而收获颇丰的旅程。从剖析 Tiptap 与 ProseMirror 的底层关系,到亲手构建自定义的 NodeMark;从掌握 addCommandsaddInputRules 等高级 API,到最终将所有知识融会贯-通,构建出一个复杂的、生产级别的 @mention 扩展。你现在所拥有的,已经不仅仅是使用 Tiptap 的能力,更是创造和扩展 Tiptap 的能力。

通过本教程的学习,我们揭示了几个关键的、超越代码本身的设计思想:

  • Tiptap 的渐进式披露:它允许开发者从简单的高层 API 入手,在需要时逐步深入到底层 ProseMirror,实现了易用性与强大功能之间的完美平衡。

  • Schema 为王:我们认识到,创建 NodeMark 的本质是设计文档的“语法”,这是一种比“添加功能”更深刻的思考方式。

  • 状态的二元性:我们区分了需要持久化的“文档状态”(attributes)和临时的“运行时状态”(storage),这是构建健壮扩展的架构基石。

  • 高级交互的三位一体:对于复杂的交互式节点,我们掌握了结合 atom 属性、addNodeViewaddProseMirrorPlugins 的核心架构模式。

掌握了这些知识,你就拥有了解锁 Tiptap 全部潜能的钥匙。你不再受限于 Tiptap 官方或社区提供的扩展,你的编辑器现在是一块真正的画布,而扩展就是你手中的画笔,可以随心所欲地描绘出你产品所需的用户体验 2。

下一步行动

  • 深入探索:官方文档永远是最好的老师。我们强烈建议你花时间深入阅读 Tiptap 和 ProseMirror 的官方文档,那里有更详尽的 API 参考和示例。

  • 动手实践:知识只有在实践中才能真正内化。尝试为你自己的项目构建一个独特的扩展,解决一个实际问题。

  • 拥抱社区:Tiptap 拥有一个活跃的社区。如果你想将自己的扩展分享给更多人,可以使用官方提供的 CLI 工具

    npm init tiptap-extension 来快速创建一个标准化的、可发布的扩展项目。

感谢你跟随本系列教程走到这里。希望这篇深度指南能够成为你在 Tiptap 定制化道路上的坚实基石和灵感源泉。祝你创造愉快!

一个超级真实的Three.js树🌲生成器插件

作者 答案answer
2025年11月18日 11:54

前言

分享一个基于Three.js封装的树生成器插件,可以实现创建不同类型且渲染效果真实的3D树

11111111111111111111111.gif

说实话,第一次在这个插件官网看到这个效果时我一度以为这只是一个视频,树的内容不仅仅是动态的而且整体的渲染效果也十分真实。

在three.js中使用起来也是非常的简单的仅仅需几行代码就可以搞定,下面给大家简单的介绍一下。

安装

通过 npm/pnpm 安装到项目本地即可

npm i @dgreenheck/ez-tree

pnpm add @dgreenheck/ez-tree

使用

使用起来也是非常简单的,只需要将插件import 引入然后在 new 实例化出来 在添加到 场景中就可以了

最后在一个requestAnimationFrame 动画函数中更新的内容就行了

import { Tree } from '@dgreenheck/ez-tree';

createTree(){

      const tree = new Tree();
      tree.generate();
      // 设置一下位置
      tree.position.set(0, 0, 0);
      // 设置一下大小缩放
      tree.scale.set(0.1, 0.1, 0.1);
      // 添加到场景中
      this.scene.add(tree);
      
}

  sceneAnimation(): void {
    // 确保动画循环持续进行
    this.renderAnimation = requestAnimationFrame(() => this.sceneAnimation());

      // 更新时钟
      const elapsedTime = this.clock.getElapsedTime();


      // 更新控制器 如果当前是第一人称控制器则不更新
      if (!this.pointerLockControls) {
        this.controls.update();
      }

      // 更新 Tree 动态效果(风动效果等)
      if (this.tree) {
        this.tree.update(elapsedTime);
      }
      // 渲染场景
      this.renderer.render(this.scene, this.camera);
  }

本地项目效果

因为本地项目对光照等参数没有专门调试所以和官网展示的效果有一定的差距

image.png

将相机放大查看树渲染的效果细节处理个人觉得是非常nice的,十分真实

image.png

参数

该插件还提供了创建不同类型树的方法,通过官网的在线调试就可以看到效果了

创建一个别的类型树

image.png

修改树枝的方向

image.png

树叶的多少

image.png

项目地址

该项目插件是一个外国大佬开发,如果你的项目或者个人网站需要丰富一下页面内容,那么这个插件或许是个不错的选择

官网:www.eztree.dev/

项目地址:github.com/dgreenheck/…

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

作者 ErpanOmer
2025年11月18日 11:39

image.png

我们每天的开发,可能都是从一个npm run dev开始的。npm scripts对我们来说,天天用它,但很少去思考它。

不信,你看看你项目里的package.json,是不是长这样👇:

"scripts": {
  "dev": "vite",
  "build": "rm -rf dist && tsc && vite build", // 嘿,眼熟吗?
  "lint": "eslint .",
  "lint:fix": "eslint . --fix",
  "test": "vitest",
  "test:watch": "vitest --watch",
  "preview": "vite preview"
}

这能用吗?当然能用。

但这专业吗?在我看来,未必!

一个好的scripts,应该是原子化的、跨平台的。而上面这个,一个build命令就不行,而且rm -rf在Windows上还得装特定环境才能跑🤷‍♂️。

今天,我就来聊聊,如何用prepost--,把你的脚本,升级成专业的脚本。


prepost:命令的生命周期钩子

prepost,是npm内置的一种钩子机制。

它的规则很简单:

  • 当你执行npm run xyz时,npm自动先去找,有没有一个叫prexyz的脚本,有就先执行它。
  • xyz执行成功后,npm自动再去找,有没有一个叫postxyz的脚本,有就最后再执行它。

这个自动的特性,就是神一般的存在。

我们来改造那个前面👆提到的build脚本。

业余写法 (用&&手动编排)

"scripts": {
  "clean": "rimraf dist", // rimraf 解决跨平台删除问题
  "lint": "eslint .",
  "build:tsc": "tsc",
  "build:vite": "vite build",
  "build": "npm run clean && npm run lint && npm run build:tsc && npm run build:vite"
}

你的build脚本,它必须记住所有的前置步骤。如果哪天你想在build前,再加一个test,你还得去修改build的定义。这违反了单一职责

专业写法 (用pre自动触发)

"scripts": {
  "clean": "rimraf dist",
  "lint": "eslint .",
  "test": "vitest run",
  "build:tsc": "tsc",
  "build:vite": "vite build",

  // build的前置钩子
  "prebuild": "npm run clean && npm run lint && npm run test", 
  
  // build的核心命令
  "build": "npm run build:tsc && npm run build:vite",
  
  // build的后置钩子
  "postbuild": "echo 'Build complete! Check /dist folder.'"
}

看到区别了吗?

现在,当我只想构建时,我依然执行npm run build。

npm会自动帮我执行prebuild(清理、Lint、测试)👉 然后执行build(编译、打包)👉 最后执行postbuild(打印日志)。

我的build脚本,只关心构建这件事。而prebuild脚本,只关心前置检查这件事

这就是单一职责和关注点分离。

你甚至可以利用这个特性,搞点骚操作😁:

"scripts": {
  // 当你执行npm start时,它会自动先执行npm run build
  "prestart": "npm run build", 
  "start": "node dist/server.js"
}

-- (双短线):脚本参数

--是我最爱的一个特性。它是一个参数分隔符

它的作用是:告诉npm,我的npm参数到此为止了,后面所有的东西,都原封不动地,传给我要执行的那个底层命令。”

我们来看开头👆那个脚本:

"scripts": {
  "test": "vitest",
  "test:watch": "vitest --watch"
}

为了一个--watch参数,你复制了一个几乎一模一样的脚本。如果明天你还想要--coverage呢?再加一个test:coverage?这叫垃圾代码💩

专业写法 (用--动态传参)

"scripts": {
  "test": "vitest"
}

就这一行,够了。

等等,那我怎么跑watch和coverage?

答案,就是用--🤷‍♂️:

# 1. 只跑一次
$ npm run test -- --run
# 实际执行: vitest --run

# 2. 跑watch模式
$ npm run test -- --watch
# 实际执行: vitest --watch

# 3. 跑覆盖率
$ npm run test -- --coverage
# 实际执行: vitest --coverage

# 4. 跑某个特定文件
$ npm run test -- src/my-component.test.ts
# 实际执行: vitest src/my-component.test.ts

--就像一个参数隧道 ,它把你在命令行里,跟在--后面的所有参数,原封不动地扔给了vitest命令。


一个专业的CI/CD脚本

好了,我们把pre/post--结合起来,看看一个专业的package.json是长什么样子👇。

"scripts": {
  // 1. Lint
  "lint": "eslint .",
  "lint:fix": "eslint . --fix",

  // 2. Test
  "test": "vitest",
  "pretest": "npm run lint", // 在test前,必须先lint

  // 3. Build
  "build": "tsc && vite build",
  "prebuild": "npm run test -- --run", // 在build前,必须先test通过
  
  // 4. Publish (发布的前置钩子)
  // prepublishOnly 是一个npm内置的、比prepublish更安全的钩子
  // 它只在 npm publish 时执行,而在 npm install 时不执行
  "prepublishOnly": "npm run build" // 在发布前,必须先build
}

看看我们构建了怎样一条自动化脚本:

  1. 你兴高采烈地敲下npm publish,准备发布。
  2. npm一看,有个prepublishOnly,于是它先去执行npm run build
  3. npm一看,build有个prebuild,于是它又先去执行npm run test -- --run
  4. npm一看,test有个pretest,于是它又双叒叕先去执行npm run lint

最终的执行流是:Lint -> Test -> Build -> Publish

这些脚本,被pre钩子,自动地、强制地串联了起来。你作为开发者,根本没有机会犯错。你不可能发布一个连Lint都没过或者测试未通过的包😁。


npm scripts,它不是一个简单的脚本快捷方式。它是一个工作流(Workflow)的定义

prepost,定义了你工作流的执行顺序依赖,保证了代码检查等功能,而--是确保你工作流中的脚本参数

现在,马上去打开你项目的package.json,看看它,是专业的,还是业余的呢?🤣

😱一行代码引发的血案:展开运算符(...)竟让图表功能直接崩了!

2025年11月18日 11:27

前言:一个看似简单的 bug

Hello~大家好。我是秋天的一阵风

最近在负责开发我司的一个图表功能时,遇到了一个令人困惑的问题。用户反馈在特定操作下会出现 Maximum call stack size exceeded 错误,但这个问题只在特定条件下出现:选择少量参数正常,但添加大量参数后就会崩溃。

经过深入调试,我发现问题的根源竟然是一行看似无害的代码

const rightYMinMax = [Math.min(...rightYData), Math.max(...rightYData)];

rightYData 数组包含 189,544 个元素时,这行代码导致了堆栈溢出。

这个Bug让我不禁开始思考:在 JavaScript 开发中,简洁的代码一定是最好的代码吗?

为了彻底搞清楚问题本质并找到最优解决方案,我在家中编写了完整的复现案例,通过对比不同实现方式的性能表现,总结出一套可落地的优化方案。

一、问题本质:为什么展开运算会导致栈溢出?

要解决问题,首先要理解问题的根源。在 JavaScript 中,Math.min()Math.max() 是全局函数,它们接收的是可变参数列表(而非数组),而展开运算符(...) 的作用是将数组元素「拆解」成一个个独立参数传递给函数。

1. 调用栈的限制

JavaScript 引擎对函数调用栈的深度有严格限制,不同浏览器和环境下的限制略有差异(通常在 1 万 - 10 万级别的参数数量)。当我们使用 Math.min(...largeArray) 时,相当于执行:

Math.min(1.23, 4.56, 7.89, ..., 999.99); // 参数数量 = 数组长度

当参数数量超过引擎的调用栈阈值时,就会触发「栈溢出」错误。在我们的项目中,

这个阈值大约是 18 万个参数 —— 这也是为什么少量参数正常,18 万 + 参数崩溃的核心原因。

2. 代码层面的隐藏风险

很多开发者喜欢用展开运算符处理数组,因为代码简洁直观。但这种写法在「小数据量」场景下看似无害,一旦数据量增长(比如图表数据、列表数据),就会瞬间暴露风险。更隐蔽的是,这种问题在开发环境中很难复现(开发环境数据量小),往往要等到生产环境才会爆发。

二、复现案例:三种方案的性能对比

为了验证不同实现方式的稳定性和性能,我编写了完整的测试代码(基于 Vue3 + TypeScript),通过「展开运算符」「循环遍历」「Reduce 方法」三种方案,在 10 万、50 万、100 万级数据量下进行对比测试。

1. 核心测试代码

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

// 测试数据大小(10万、50万、100万)
const testSizes = ref([100000, 500000, 1000000]);

// 定义测试结果类型
interface TestResult {
  method: string;
  success: boolean;
  result: number[] | null;
  time: number;
  error: string | null;
}
interface TestData {
  size: number;
  tests: TestResult[];
}
const results = ref<TestData[]>([]);

// 生成随机测试数据
const generateTestData = (size: number) => {
  return Array.from({ length: size }, () => Math.random() * 1000);
};

// 方案1:原始方法(展开运算符,会栈溢出)
const getMinMaxWithSpread = (data: number[]): TestResult => {
  try {
    const start = performance.now();
    const result = [Math.min(...data), Math.max(...data)]; // 风险代码
    const end = performance.now();
    return {
      method: '展开运算符',
      success: true,
      result,
      time: end - start,
      error: null
    };
  } catch (error: any) {
    return {
      method: '展开运算符',
      success: false,
      result: null,
      time: 0,
      error: error.message
    };
  }
};

// 方案2:优化方案(循环遍历)
const getMinMaxWithLoop = (data: number[]): TestResult => {
  try {
    const start = performance.now();
    let min = data[0];
    let max = data[0];
    for (let i = 1; i < data.length; i++) {
      if (data[i] < min) min = data[i];
      if (data[i] > max) max = data[i];
    }
    const end = performance.now();
    return {
      method: '循环遍历',
      success: true,
      result: [min, max],
      time: end - start,
      error: null
    };
  } catch (error: any) {
    return {
      method: '循环遍历',
      success: false,
      result: null,
      time: 0,
      error: error.message
    };
  }
};

// 方案3:优化方案(Reduce方法)
const getMinMaxWithReduce = (data: number[]): TestResult => {
  try {
    const start = performance.now();
    const result = data.reduce(
      (acc, curr) => [Math.min(acc[0], curr), Math.max(acc[1], curr)],
      [data[0], data[0]]
    );
    const end = performance.now();
    return {
      method: 'Reduce方法',
      success: true,
      result,
      time: end - start,
      error: null
    };
  } catch (error: any) {
    return {
      method: 'Reduce方法',
      success: false,
      result: null,
      time: 0,
      error: error.message
    };
  }
};

// 执行测试
const runTests = () => {
  console.log('### 开始测试栈溢出问题...');
  results.value = [];
  
  testSizes.value.forEach(size => {
    console.log(`### 测试数据大小: ${size.toLocaleString()}`);
    const testData = generateTestData(size);
    
    // 执行三种方案的测试
    const testResult = {
      size,
      tests: [
        getMinMaxWithSpread(testData),
        getMinMaxWithLoop(testData),
        getMinMaxWithReduce(testData)
      ]
    };
    
    results.value.push(testResult);
    
    // 打印控制台结果
    testResult.tests.forEach(test => {
      if (test.success && test.result) {
        console.log(`### ${test.method}: 成功 - 耗时 ${test.time.toFixed(2)}ms - 结果: [${test.result[0].toFixed(2)}, ${test.result[1].toFixed(2)}]`);
      } else {
        console.log(`### ${test.method}: 失败 - ${test.error}`);
      }
    });
    console.log('### ---');
  });
};

// 页面挂载时执行测试
onMounted(() => {
  runTests();
});
</script>

<template>
  <div style="padding: 20px; font-family: Arial, sans-serif;">
    <h1>Math.min/max 栈溢出问题测试</h1>
    
    <div style="margin: 20px 0;">
      <h2>问题描述</h2>
      <p>当数组元素数量过大时,使用展开运算符 <code>Math.min(...array)</code> 会导致栈溢出错误。</p>
      <p>原因:展开运算符会将所有数组元素作为参数传递给函数,超出JavaScript引擎的调用栈限制。</p>
    </div>
    
    <div style="margin: 20px 0;">
      <button @click="runTests" style="padding: 10px 20px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">
        重新运行测试
      </button>
    </div>
    
    <div style="margin: 20px 0;">
      <h2>测试结果</h2>
      <div v-for="result in results" :key="result.size" style="margin: 15px 0; padding: 15px; border: 1px solid #ddd; border-radius: 4px;">
        <h3>数据大小: {{ result.size.toLocaleString() }} 个元素</h3>
        <div v-for="test in result.tests" :key="test.method" style="margin: 10px 0; padding: 10px; background: #f8f9fa; border-radius: 4px;">
          <div style="display: flex; justify-content: space-between; align-items: center;">
            <strong>{{ test.method }}</strong>
            <span :style="{ color: test.success ? 'green' : 'red' }">
              {{ test.success ? '✓ 成功' : '✗ 失败' }}
            </span>
          </div>
          <div v-if="test.success" style="margin-top: 5px;">
            <div>耗时: {{ test.time.toFixed(2) }}ms</div>
            <div v-if="test.result">结果: [{{ test.result[0].toFixed(2) }}, {{ test.result[1].toFixed(2) }}]</div>
          </div>
          <div v-else style="margin-top: 5px; color: red;">
            错误: {{ test.error }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
image.png

2. 测试结果分析(Chrome 浏览器环境)

通过实际运行测试代码,我得到了以下关键结果(数据为多次测试平均值):

数据量 展开运算符 循环遍历 Reduce 方法
10 万元素 成功(1.6ms) 成功(0.2ms) 成功(1.4ms)
50 万元素 失败(栈溢出错误) 成功(0.4ms) 成功(4.4ms)
100 万元素 失败(栈溢出错误) 成功(0.6ms) 成功(23.9ms)

从结果中可以得出两个核心结论:

  1. 稳定性:展开运算符在数据量超过 10 万后就会触发栈溢出,而循环遍历和 Reduce 方法在 100 万级数据下仍能稳定运行;

  2. 性能:循环遍历的性能最优(耗时最短),Reduce 方法略逊于循环(函数调用有额外开销),展开运算符在小数据量下表现尚可,但稳定性极差。

三、解决方案:从修复到预防

针对「Math.min/max 处理大数据数组」的问题,我们可以从「即时修复」和「长期预防」两个层面制定方案。

方案 1:循环遍历(性能最优)

适用于对性能要求高的场景(如大数据图表、实时计算),代码如下:

const getMinMax = (data: number[]): [number, number] => {
  if (data.length === 0) throw new Error('数组不能为空');
  let min = data[0];
  let max = data[0];
  for (let i = 1; i < data.length; i++) {
    min = Math.min(min, data[i]);
    max = Math.max(max, data[i]);
  }
  return [min, max];
};const getMinMax = (data: number[]): [number, number] => {
  if (data.length === 0) throw new Error('数组不能为空');
  let min = data[0];
  let max = data[0];
  for (let i = 1; i < data.length; i++) {
    min = Math.min(min, data[i]);
    max = Math.max(max, data[i]);
  }
  return [min, max];
};

方案 2:Reduce 方法(代码简洁)

适用于代码风格偏向函数式编程的场景,代码如下:

const getMinMax = (data: number[]): [number, number] => {
  if (data.length === 0) throw new Error('数组不能为空');
  return data.reduce(
    (acc, curr) => [Math.min(acc[0], curr), Math.max(acc[1], curr)],
    [data[0], data[0]]
  );
};

方案 3:分批处理(超大数据量)

当数据量达到「千万级」时,即使循环遍历也可能有内存压力,此时可以分批处理:

const getMinMaxBatch = (data: number[], batchSize = 100000): [number, number] => {
  if (data.length === 0) throw new Error('数组不能为空');
  let min = data[0];
  let max = data[0];
  
  // 分批处理
  for (let i = 0; i < data.length; i += batchSize) {
    const batch = data.slice(i, i + batchSize);
    for (const num of batch) {
      min = Math.min(min, num);
      max = Math.max(max, num);
    }
  }
  
  return [min, max];
};

方案 4:长期预防:建立编码规范

为了避免类似问题再次发生,我们还可以考虑在团队中建立以下编码规范:

  1. 禁止用展开运算符处理未知大小的数组:如果数组长度可能超过 1 万,坚决不用 Math.min(...array) 或 Math.max(...array);
  1. 优先选择循环遍历处理大数据:在性能敏感场景(如数据可视化、列表筛选),优先使用 for 循环而非 Reduce 或其他函数式方法;
  1. 添加数据量校验:对输入数组的长度进行限制,超过阈值时给出警告或分批处理;
  1. 单元测试覆盖边界场景:在单元测试中加入「大数据量」场景(如 10 万、100 万元素),提前暴露问题。

四、总结:跳出「简洁代码」的陷阱

这个看似简单的栈溢出问题,给我们带来了三个深刻的启示:

  1. 简洁不等于优质:很多开发者追求「一行代码解决问题」,但忽略了代码的稳定性和性能。在 JavaScript 中,展开运算符、eval、with 等语法虽然简洁,但往往隐藏着风险;
  1. 关注数据量变化:前端开发不再是「小数据时代」,随着图表、大数据列表、实时数据流的普及,我们必须在代码设计阶段就考虑「大数据场景」;
  1. 重视边界测试:开发环境中的「小数据测试」无法覆盖生产环境的「大数据场景」,必须通过边界测试(如最大数据量、空数据、异常数据)验证代码稳定性。

最后,用一句话总结:优秀的前端工程师,不仅要写出「能运行」的代码,更要写出「稳定、高效、可扩展」的代码。这个栈溢出问题,正是我们从「会写代码」到「写好代码」的一次重要成长。

揭秘高性能协同白板:轻松实现多人实时协作(一)

作者 朴shu
2025年11月18日 11:19

前言

目前成熟的白板工具已经很多了,想探索下内部的实现原理,为远程团队协作、在线教育、设计评审和头脑风暴场景设计,通过高效的 Konva 渲染引擎和 Yjs 协同算法,实现多人实时操作的无缝协同体验,同时保持高性能的图形处理能力,流畅的操作体验。

现存的白板工具仍有些小问题,例如:难以集成到项目中二开难度大开发者不友好等,本应用意在提供完整白板功能的基础上,解决上诉提到的难点,并创新添加一些新特性,用于丰富白板的数据展示能力。

demo.gif

数据结构设计

在应用开发之前,我们先设计一下应用的数据存储结构,一个好的数据结构,可以为我们省去很多麻烦。

  1. 为了实现图形节点附加文本的效果(双击添加文本),我们利用 konva Group 组的概念,将图形节点和文本节点,添加到组中,以实现整体的平移、缩放、变换等效果。
const group = new Konva.Group();
const shape = new Konva.Rect({...});
const text = new Konva.Text({...});
group.add(shape, text);
  1. 本例采用 Yjs 分布式协同特性,因此,整个应用 AppData 设计为 Y.Map,如下:
// 定义整个应用的数据,key 为 shapeID,value 为 ShapeItem
export type AppData = Map<ShapeItem>;

// 每一个 Konva 图形的属性
export interface ShapeItem {
id: string; // 图形id
type: string; // 图形类型 - 用于判断图形类型 'group' | 'rect'|  'circle' | 'ellipse' | 'image' | 'text' | ...
locked?: boolean; // 是否锁定
visible?: boolean; // 是否可见
children?: Array<string>; // 子节点ID数组(用于组合节点) - 用于判断图形是否为组合图形
isCombined?: boolean; // 标记是否被组合
attrs?: Record<string, unknown>; // 拓展属性
group: GroupConfig; // Step 1 render 时,先根据 group 创建一个 group
shape: ShapeConfig; // Step 2 render 时,根据 shape 创建一个 shape
text: TextConfig; // Step 3 render 时,根据 text 创建一个 text
}
  1. 抽象数据操作示例如下:
// 根节点
const appData = this.doc.getMap('appData');

// 添加一个图形
const rectConfig = ShapeController.createShape('rect', {...});
appData.set(rectConfig.id, rectConfig)

// 删除一个图形
appData.delete(rectConfig.id);

// 修改图形属性
appData.set(rectConfig.id, {...rectConfig, x: 100, y: 100 });

注意: 修改属性请使用 map.set() 而不是 shape.x = 100,这种模式不能引起 Y.Map 的数据变化回调。

协同控制中心

在协同应用场景,应当包含以下几个协同模块:

用户感知

用户感知是协同中的重要部分,可以通过感知获取其他用户的位置信息及实时操作状态

// 创建 awareness 实例
this.awareness = new Awareness(this.doc);

// 添加本地感知状态
const { userId, userName, userColor } = this.user.getUserInfo();
this.awareness.setLocalState({ userId, userName, userColor });
// 监听感知状态
encodeAwarenessUpdate(
this.awareness,
Array.from(this.awareness.getStates().keys())
);
this.awareness.on('update', this.handleAwarenessUpdate.bind(this));

除了用户信息,还可以将用户光标位置等信息,一同发送给其他客户端,因此需要提供更新光标的方法:

/**
 * @description 更新感知状态
 * @param { [key: string]: any  } 更新的数据
 * @returns
 */
public updateAwareness(data: Record<string, unknown> = {}) {
this.awareness.setLocalState({
...this.awareness.getLocalState(),
...data,
});
}

后续的具体光标实现,到绘制图层时,在详细说明哈

提供程序

提供程序是协同的重点,不同的提供程序对协同设计有着不同的效果,选择合适的提供程序,也是协同时效性、稳定性的考验

// websocket 方式实现协同 - 提供程序默认支持传递 awareness
this.provider = new WebsocketProvider(url, roomname, this.doc, {
awareness,
params,
});

撤销管理

协同应用,较难的就是分布式撤销,Yjs内置的 Y.UndoManager 支持分布式撤销,这为我们协同设计提供了便利

this.undoManager = new UndoManager(doc, {
captureTimeout: 500,
 // 添加用户源(特别注意这里,需要与 transaction origin保持一致!)
trackedOrigins: new Set([origin]),
});

Y.UndoManager 直接关联的就是 history 历史记录,具体如下:

constructor(collaborateManager: CollaborateManager) {
this.undoManager = collaborateManager.getUndoManager();
}

public undo() {
// 如果不可撤销
if (!this.undoManager.canUndo()) return console.warn('⚠️ 不可撤销!');
this.undoManager.undo();
}

public redo() {
// 如果不可重做
if (!this.undoManager.canRedo()) return console.warn('⚠️ 不可重做!');
this.undoManager.redo();
}

用户系统

用户系统指的是当前协同的用户信息,包括用户ID 、userName、以及用户协同颜色,同时,在撤销管理和协同事件源中,都有一个origin,是与当前用户相关联的信息,也将其纳入用户信息管理中

this.userId = userId;
this.userName = userName;
this.userColor = userColor;

const originId = generateKey();
this.origin = { originId, userId: this.userId };

协同入口

入口文件提供必要的方法,其中最重要的,就是 transaction 事务执行方法,将对数据的操作,封装到事务中,便于 UndoManager 记录

public transaction = (fn: () => void) => {
// 使用与 UndoManager 中一致的 origin 对象
const origin = this.user.getOrigin();
this.doc.transact<void>(fn, origin);
};

绘制实现

既然是白板应用,那相关的绘制实现肯定是最重要,也是最难的,下面将这部分详细讲解下。Konva 是以树形结构组织的结构,stage 就是根节点,所有的图层都需要添加到 stage 上

// 初始化 konva
this.stage = this.initStage();

为了实现精细化管理,针对图层、图形、数据部分进行抽象及封装,架构如下: 在这里插入图片描述

  1. 协同层主要维护应用数据,监听数据变化,驱动视图更新,同时处理 UndoManager,实现撤销管理;
  2. 用户的页面操作,会映射为图形的具体操作,例如添加图形、更新图形、删除图形,用户并非真实在操作图形,而是通过操作代理在操作数据,引发 appData 的变化;
  3. 数据变化后,会调用 render 进行视图更新,在这个方法内,就需要真实的操作 Konva 类,将数据转换为页面上的图形。

图层管理

为了后续对图层的操作更便捷,应该将图层的相关方法抽离为独立的模块:

  • backgroundLayer:背景图层,实现背景颜色、网格线绘制;
  • shapeLayer:形状图层,实现元素的绘制、缩放、平移等操作区;
  • toolbarLayer: 工具图层,用于绘制协同光标、选区、可视区、其他辅助信息。 请添加图片描述

这样,后续的所有图层操作,都可以精细到具体的图层,例如,修改背景颜色、绘制用户光标、绘制对齐线等

图形管理

本例采用的是数据驱动的形式实现的图形绘制,因此,需要劫持用户对图形的操作,将其转换为数据处理,如下:

  • 原模式:直接操作konva进行绘制
const rect = new Konva.Rect({...})
layer.add(rect)
  • 数据驱动模式:对数据进行维护,使得数据驱动视图更新
const appData = this.doc.getMap()
appData.observeDeep(this.draw.render)

// 这里会返回 具体的配置项 而不是具体的图形
const rectConfig = ShapeController.createShape('rect') 

// 将返回的配置项添加到 Y.Map 中
appData.set(rectConfig.id, rectConfig)

// 数据变化后,会引起 render 绘制
function render(){
// 根据 appData 真实渲染 konva 图形
}

这种模式有一个好处,只需要关心数据的变化,在 render 中进行统一进行konva图形绘制处理即可。同时,还能劫持用户对图形的操作,实现更多拓展功能。

数据驱动

数据驱动是本例重要实现,需要根据 Y.Map 的数据,渲染出当前画布结构:

// 监听数据更新,驱动视图渲染
private appDataUpdateHandler(event: YMapEvent<ShapeItem>) {
// 这个 key 是当前发生变化的 shapeItem.id
event.changes.keys.forEach((change, key) =>
this.draw.render(change, key)
);
}

public render(change: YMapChange, id: string) {
console.log('✨️ patch render');

// 1. 获取当前图形 id 在数据中的配置
const appData = this.collaborateManager.gettAppData();
const shapeConfig = appData.get(id);

// 2. 获取当前图层画布
const shapeLayer = this.layerController.getShapeLayer();

// 3. 通过 change 识别当前的操作类型 add | update | delete
if (change.action === 'add' && shapeConfig) {
shapeLayer.addShape(shapeConfig);
} else if (change.action === 'update' && shapeConfig) {
shapeLayer.updateShape(shapeConfig);
} else if (change.action === 'delete') {
shapeLayer.deleteShape(id);
}
// 4. 重新渲染图层
this.stage.batchDraw();
}

在这里插入图片描述

同时,在 render 函数中,还可以做一些绘制优化,例如:实现增量更新(脏矩形渲染)、视口剔除(视口裁剪)等,可以在一定程度上减少大画布场景下的渲染压力

事件委托

本例采用数据驱动更新,因此图形可能随时在渲染更新,如果将事件绑定到具体的图形上,那么,我们就需要处理事件解绑及绑定的时机,处理起来比较麻烦。因此,本例将事件统一绑定到 stage 上,使用 stage 进行统一的事件处理。

const stage = this.draw.getStage();
stage.on('click', (e) => console.log('stage click', e));

在这里插入图片描述

在事件源中,currentTarget 始终指向事件源绑定的对象,也就是当前的 stage,而 target 则指向当前触发事件的对象,也就是当前点击click,是点击到谁上面,evt 是原生事件源,type 指向当前事件的类型,在多事件中,需要做区分使用。

图形操作

插入形状

插入图形实现原理就是在Y.Map 中插入一条数据,驱动试图更新,流程如下:

// 创建矩形 - 这里得到的是关于这个矩形的所有可执行配置
const rectConfig = shapeCntroller.createShape('rect', {
x: 100,
y: 100,
width: 100,
height: 100,
fill: 'red',
stroke: 'black',
strokeWidth: 2,
});

const appData = this.collaborateManager.gettAppData();

// 通过事务执行,将图形添加到 appData 中
this.collaborateManager.transaction(() => {
appData.set(config.id, JSON.parse(JSON.stringify(config)));
});

上面执行后,就会引起视图更新,通过视图去真实创建 Konva.Rect:

// 这里解析 group shape text 属性
const { groupConfig, shapeConfig } = config;

// 创建真实 group
const groupNode = new Group({ ...groupConfig, draggable: true });

// 创建真实 shape 后续需要工厂模式实现图形创建
const shape = new Rect({ ...shapeConfig, draggable: false });

groupNode.add(shape);

this.layer.add(groupNode);

更新属性

更新图形属性,无非就是将更新后的 config 重新setMap 上,引发更新:

// 不然获取当前图形的属性,拼接为 ShapeItem 进行更新
const groupConfig = target.getAttrs();
const id = target.id();
const type = groupConfig.type;

// 获取shapeConfig
const shapeConfig = target.children[0].getAttrs();

// 获取textConfig
const textConfig = target.children[1]?.getAttrs();

// 触发数据更新
const config = {id, type, groupConfig, shapeConfig, textConfig};

// 触发更新
const appData = this.collaborateManager.gettAppData();
this.collaborateManager.transaction(() => {
appData.set(config.id, JSON.parse(JSON.stringify(config)));
});

试图更新方法:

// 这里有几个属性需要更新,group shape text
const { groupConfig, shapeConfig, id } = config;
const groupNode = this.findOne<Group>(`#${id}`);
if (!groupNode) return;

groupNode.setAttrs(groupConfig);
const shape = groupNode.children[0];
shape.setAttrs(shapeConfig);

删除图形

删除图形,则直接调用 Map.delete 方法即可

// 不然执行删除
this.collaborateManager.transaction(() => {
appData.delete(id);
});

试图更新方法:

this.findOne<Group>(`#${id}`)?.destroy();

在这里插入图片描述

大家好好理解一下架构,就可以理解上面的代码了哈 在这里插入图片描述

实现动态绘制

我们需要在页面上实现动态绘制,这里有几个注意事项:

  1. 不能在 mouse-xxx 事件执行过程中进行数据操作;
  2. 需要中继画布实现暂时绘制

什么意思呢?我们知道,每对一次数据操作,都会引发 Y.Map 的更新,同时,也会向 UndoManager 历史记录里插入操作记录,如果我们在 mousemove 过程中,频繁添加操作记录,那么撤销时,就会导致异常。因此,我们需要通过 最后的 mouseup 插入一条数据即可。

mousedown 主要是记录初始位置,并将必要的参数赋给 stage

// 记录下当前鼠标的位置
const { x, y } = draw.getStage().getPointerPosition()!;

const menuType = useStore().getState('activeMenu');

// 向 stage 添加属性
const stage = draw.getStage();
stage.setAttrs({ startX: x, startY: y, menuType, mousedownFlag: true, });

mousemove 处理移动中的位置以及实际的绘制参数

// 获取当前鼠标的位置
const { x, y } = stage.getPointerPosition()!;

// 向 toolbarLayer 添加属性,实现暂时绘制,最终的判断在 mouseup 事件中实现
const layerController = draw.getLayerController();
const toolbarLayer = layerController.getToolbarLayer();

// 设置绘制参数
const { menuType, startX = 0, startY = 0 } = stage.getAttrs();

toolbarLayer.setDrawParams({
menuType,
startX,
startY,
endX: x,
endY: y,
});

toolbarLayer 通过工具类图层绘制展现效果,此时不会执行数据操作,也就不会添加记录

this.layer = new Layer({ id: 'toolbarLayer', listening: false });

// 通过矩形绘制实现
const rect = new Rect({
id: 'toolbarRect',
sceneFunc: this.handleSceneFunc.bind(this),
});

// handleSceneFunc 函数内,就是原生 canvas 操作了

mouseup 中,就是根据实际的绘制参数,进行操作代理:

// 不然根据 menuType 调用操作代理,获取真实的图形配置,添加到 Y.Map 上
const operationProxy = draw.getOperationProxy();

let shapeConfig: ShapeItem | null = null;

switch (menuType) {
case 'rect':
shapeConfig = operationProxy.createShape('rect', {
x: startX,
y: startY,
width,
height,
});
break;
}

if (shapeConfig) operationProxy.addShape(shapeConfig);

这样,就能实现在 mouseup 事件中,执行一次数据操作,只会生成一次历史记录。 在这里插入图片描述

总结

本文详细介绍了基于 Konva 和 Yjs 的协同白板应用的整体架构设计与核心实现。通过数据驱动的方式,将用户操作抽象为对 Y.Map 的数据操作,实现了多人实时协同的图形编辑功能。关键设计包括:

  • 分层架构:将协同层、操作代理层和渲染层分离,确保数据流清晰可控

  • 数据驱动渲染:通过监听 Y.Map 变化实现视图自动更新

  • 事件委托机制:在 Stage 级别统一处理事件,简化事件管理

  • 动态绘制优化:通过中继画布避免频繁数据操作,保证撤销重做的正确性

  • 完整的协同生态:集成用户感知、撤销管理、实时同步等协同核心功能

这一架构为后续功能扩展奠定了坚实基础,既保证了多人协同的实时性,又提供了良好的开发体验。

在下一篇文章中,我们将深入探讨白板的高级交互功能实现:

Konva 图形控制

  • 选中与变换:实现图形的单选、多选、旋转、缩放控制点

  • 对齐吸附:智能对齐线和网格吸附系统

  • 层级管理:前置、后置、置顶、置底等层级操作

分组与组合

  • 图形分组:多选图形创建分组,支持嵌套分组

  • 组合解组:临时组合与永久组合的实现策略

  • 组内编辑:在组内直接编辑单个图形的能力

高级视觉效果

  • 滤镜系统:模糊、阴影、颜色调整等实时滤镜

  • 渐变填充:线性渐变、径向渐变的动态配置

  • 纹理图案:自定义图案填充和背景纹理

数据可视化增强

  • 图表集成:集成 VCharts 等图表库实现数据图表

  • 手绘风格:模拟手绘效果的笔刷和图形渲染

  • Latex 公式: 支持动态公式编辑

性能优化

  • 视口裁剪:大画布下的渲染性能优化

  • 增量更新:脏矩形渲染减少重绘区域

  • 内存管理:图形缓存和垃圾回收策略

通过这些功能的实现,白板将从一个简单的绘图工具升级为功能丰富的协同创作平台,满足教育、设计、会议等多样化场景的需求。

期待与您在下一篇文章中继续探索白板开发的精彩世界! 🎨✨

欢迎感兴趣的小伙伴,一起加入白板的开发,目前正在稳步推进,欢迎加入哦~

React 如何处理高频的实时数据?

作者 mCell
2025年11月18日 11:11

同步至个人站点:React 如何处理高频的实时数据?

069.png

最近,我遇到了一个很有意思的 React 问题。

我需要开发一个实时的日志查看器,功能上需要实时展示服务运行的日志。因为这个项目是内部的,我这里大概抽象一下:

后端使用 SSE(Server-Sent Events) 技术,源源不断地把日志推送给前端。

当日志一条一条、不紧不慢地过来时,一切正常。

但是,当我预览一个已经完成的任务日志时,网页卡顿了一下。浏览器控制台显示了一个 React 开发者很熟悉的错误:

Uncaught Error: Maximum update depth exceeded... (错误:超过最大更新深度)

这个错误通常意味着,存在什么组件陷入了无限循环。比如,组件的渲染函数里直接调用了 setState,导致“渲染 → 更新状态 → 触发渲染 → ...”的死循环。

比如这样:

export default function Demo() {
  const [count, setCount] = useState(0)
  setCount(count + 1)
  return <h1>Count: {count}</h1>
}

但我的代码并没有这样的逻辑,该使用 useEffect 的地方都使用了。我只是在 SSE 的事件回调里更新状态。

// 示意代码
const source = new EventSource("/api/logs")
source.addEventListener("log", (event) => {
  // 每来一条日志,就调用 set 函数
  appendLog(event.data)
})

那么,问题出在哪里呢?

问题的根源:高频更新

起初我以为是哪里的更新逻辑不对,让 claude 排查很久都没找到具体问题。在给现有函数增加了不少缓存,比如useMemouseCallback,甚至 React.memo 都使用上了,仍旧没有解决这个报错。

代码没有问题,那么问题就应该出现在一些极端场景导致的高频渲染。比如网络?我才打开控制台的网络部分,看到几乎在很短时间内,上百条的 log 被推送过来!

到这里问题就和清晰了:当服务器在短时间内(比如 1 秒内)推送上百条日志时,每一个 log 都触发了 React 进行重新渲染,这里触发了 React 的某些机制,React 对这种行为发出了报错。

React 内部有一个“嵌套更新计数器”,用来防止无限循环。

简单说,如果在一次渲染(Render)的过程中,又因为某些原因触发了新的状态更新,这就叫“嵌套更新”。当这个次数短时间内超过一个阈值(通常是 50 次),React 就会认为你“可能”写了一个 Bug,于是主动抛出错误,终止程序。

我们的问题就出在这里。SSE 的事件回调来得太快了。

当服务器在 1 秒内推送 150 条日志时,浏览器的事件循环会疯狂执行回调:

  1. SSE 事件 1 抵达 → appendLog() → 触发 React 更新(第 1 次)
  2. React 还没来得及渲染,SSE 事件 2 抵达 → appendLog() → 触发 React 更新(第 2 次)
  3. ...
  4. SSE 事件 50 抵达 → appendLog() → 触发 React 更新(第 50 次)
  5. SSE 事件 51 抵达 → appendLog() → 触发 React 更新(第 51 次)

在 React 看来,这 51 次更新几乎是“同时”发生的,它无法分辨这是“51 条独立日志”还是“一个死循环”。为了保护自己,它选择了报错。

问题的本质是:数据接收的频率(高频)和 React 状态更新的频率(低频)不匹配。

我们不能每收到一条数据,就立刻更新一次状态。

后续我了解到 React 18 版本对高频渲染的问题进行了优化,但它目前仅适用于 React 事件处理函数内的同步更新。对于 SSE 回调、fetch 回调、setInterval 等异步事件源触发的更新,仍需手动实现批处理。

解决方案:批处理(Batching)

既然不能一条一条地更新,那很自然就想到,能不能把日志“攒一下”,再一次性提交给 React?

这就是“批处理”(Batching)思想。

我们不再是“来一条,更新一次”,而是“来 N 条,更新一次”。

实现这个功能的关键,是需要一个“缓冲区”(Buffer)和一个“定时器”(Timer)。

  1. 缓冲区:需要一个地方暂存日志,但这个地方本身不能是 React 的 state(否则又触发渲染了)。useRef 是最合适的人选。
  2. 定时器:需要一个机制,在“攒”日志的间隙,把它们统一提交。setTimeout(..., 0) 是这里的法宝。

代码实现

我们来改造一下 log 事件的处理。

首先,在组件里定义缓冲区和定时器:

export default function LogPage() {
  // 1. 从 store 获取批量更新的方法
  const appendLogs = useLogStore((state) => state.appendLogs)

  // 2. 批处理缓冲区(使用 ref 不会触发渲染)
  const batchBufferRef = useRef([])

  // 3. 定时器引用(保证只有一个定时器在运行)
  const batchTimerRef = useRef(null)

  // ...
}

其次,实现一个“提交缓冲区”的函数 flushBatch

// 4. 批量提交函数
const flushBatch = useCallback(() => {
  // 如果缓冲区有数据
  if (batchBufferRef.current.length > 0) {
    // 一次性提交给 store
    appendLogs(batchBufferRef.current)
    // 清空缓冲区
    batchBufferRef.current = []
  }
  // 重置定时器引用
  batchTimerRef.current = null
}, [appendLogs]) // 依赖 appendLogs

最后,修改 SSE 的事件处理函数 handleLogEvent

// 5. 新的 SSE 事件处理函数
const handleLogEvent = useCallback(
  (event) => {
    const entry = {
      /* ...解析日志... */
    }

    // 重点:不再直接调用 appendLog
    // 而是将日志加入缓冲区
    batchBufferRef.current.push(entry)

    // 如果还没有计划批处理,则在下一个事件循环中执行
    if (batchTimerRef.current === null) {
      batchTimerRef.current = window.setTimeout(flushBatch, 0)
    }
  },
  [flushBatch] // 依赖 flushBatch
)

为什么是 setTimeout(..., 0)

你可能会问,为什么是 setTimeout(..., 0)

这是一个很巧妙的技巧。它并不是真的“延迟 0 毫秒”,而是告诉浏览器:“请在当前这一轮事件循环(Event Loop)的同步代码都执行完之后,再执行这个 flushBatch 函数。”

当 150 条日志在短时间内涌入时,会发生什么?

  1. 事件 1 抵达 → push 到缓冲区 → setTimeout 注册一个 flushBatch 回调。
  2. 事件 2 抵达 → push 到缓冲区 → 检查定时器,发现已有,跳过。
  3. 事件 3 抵达 → push 到缓冲区 → 跳过。
  4. ...
  5. 事件 150 抵达 → push 到缓冲区 → 跳过。
  6. (当前宏任务结束,所有同步代码执行完毕)
  7. 浏览器从任务队列中取出 flushBatch 回调,执行。
  8. flushBatch 函数将 150 条日志一次性提交给 React。

于是,150 次 setState 调用,被神奇地合并成了 1 次。应用流畅如初。

(完)

一文读懂 Uniapp 小程序登录流程

作者 Lsx_
2025年11月18日 11:01

一、微信小程序登录原理

登录的标准流程如下:

步骤 动作 说明
1 小程序端调用 uni.login() 获取 code(临时登录凭证)
2 小程序端把 code 发给自己的服务器 通常是 /api/login
3 服务器请求微信接口 https://api.weixin.qq.com/sns/jscode2session 用 appid + secret + code 换取 openid 和 session_key
4 服务器生成自己的 token 返回前端 对应平台用户身份
5 小程序保存 token 并登录成功 下次启动可直接使用

二、前端开发(UniApp + Vue3)

登录页面代码

/pages/login/login.vue

<template>
  <view class="container">
    <view class="title">欢迎使用小程序</view>
    <button @click="handleLogin" class="login-btn">微信一键登录</button>
  </view>
</template>

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

const token = ref('')
const user = ref({})

// 微信登录
const handleLogin = async () => {
  try {
    // 1. 获取 code
    const { code } = await new Promise((resolve, reject) => {
      uni.login({
        provider: 'weixin',
        success: res => resolve(res),
        fail: err => reject(err)
      })
    })
    console.log('小程序登录凭证 code:', code)

    // 2. 发送到后端
    const [err, res] = await uni.request({
      url: 'http://localhost:3000/api/login', // 后端接口地址
      method: 'POST',
      data: { code }
    })
    if (err) throw new Error('请求失败')

    // 3. 处理后端返回数据
    token.value = res.data.token
    user.value = res.data.user

    uni.setStorageSync('token', token.value)
    uni.setStorageSync('userInfo', user.value)

    uni.showToast({ title: '登录成功', icon: 'success' })
    console.log('登录结果:', res.data)
  } catch (e) {
    console.error(e)
    uni.showToast({ title: '登录失败', icon: 'none' })
  }
}
</script>

<style>
.container {
  margin-top: 200rpx;
  text-align: center;
}
.title {
  font-size: 40rpx;
  margin-bottom: 80rpx;
}
.login-btn {
  background-color: #07c160;
  color: #fff;
  width: 80%;
  border-radius: 12rpx;
}
</style>

✅ 点击按钮会自动执行微信登录流程。


三、后端实现(Node.js)

1️⃣ 环境搭建

创建一个 server 文件夹,执行命令:

npm init -y
npm install express axios jsonwebtoken cors

2️⃣ 创建 server.js

const express = require('express')
const axios = require('axios')
const jwt = require('jsonwebtoken')
const cors = require('cors')

const app = express()
app.use(cors())
app.use(express.json())

// 你的微信小程序信息
const APPID = '你的小程序AppID'
const SECRET = '你的小程序AppSecret'

// token密钥
const JWT_SECRET = 'my_secret_key'

// 模拟数据库
const users = {}

// 登录接口
app.post('/api/login', async (req, res) => {
  const { code } = req.body
  if (!code) return res.status(400).json({ message: '缺少code' })

  try {
    // 请求微信API换取openid和session_key
    const response = await axios.get(
      `https://api.weixin.qq.com/sns/jscode2session`, {
        params: {
          appid: APPID,
          secret: SECRET,
          js_code: code,
          grant_type: 'authorization_code'
        }
      }
    )

    const { openid, session_key, errcode, errmsg } = response.data
    if (errcode) {
      return res.status(400).json({ message: '微信登录失败: ' + errmsg })
    }

    console.log('用户openid:', openid)

    // 模拟创建/更新用户
    if (!users[openid]) {
      users[openid] = { openid, createTime: new Date() }
    }

    // 生成自定义 token
    const token = jwt.sign({ openid }, JWT_SECRET, { expiresIn: '7d' })

    return res.json({
      token,
      user: users[openid]
    })
  } catch (err) {
    console.error(err)
    res.status(500).json({ message: '服务器错误' })
  }
})

// 验证 token 接口(示例)
app.get('/api/profile', (req, res) => {
  const auth = req.headers.authorization
  if (!auth) return res.status(401).json({ message: '请先登录' })

  try {
    const decoded = jwt.verify(auth.replace('Bearer ', ''), JWT_SECRET)
    res.json({ user: users[decoded.openid] })
  } catch {
    res.status(401).json({ message: 'token无效' })
  }
})

// 启动服务器
app.listen(3000, () => {
  console.log('✅ Server running on http://localhost:3000')
})

✅ 启动命令:

node server.js

四、运行测试

  1. 启动后端:

    node server.js
    
  2. 打开 HBuilderX → 运行 → 运行到微信开发者工具

  3. 点击 “一键登录”,查看控制台输出

    • 小程序端日志会打印 code
    • 后端会打印 openid
  4. Toast 提示 “登录成功” ✅


五、持久化登录

登录成功后:

  • 将 token 存到 uni.setStorageSync('token', token)
  • 下次启动时判断本地是否有 token,有则不再重复登录。
// 在 App.vue 的 onLaunch 中
onLaunch() {
  const token = uni.getStorageSync('token')
  if (token) {
    console.log('用户已登录:', token)
  } else {
    uni.reLaunch({ url: '/pages/login/login' })
  }
}

六、重点总结

模块 工具 功能
前端 uni.login() 获取 code
前端 uni.request() 调后端登录接口
后端 axios 请求微信接口 用 code 换 openid
后端 jsonwebtoken 生成自定义 token
前端 uni.setStorageSync 保存 token

✅ 最终效果:

  • 点击登录按钮 → 自动通过微信授权登录
  • 后端生成 token 并返回
  • 前端保存登录状态
  • 可扩展为用户注册、绑定手机号等功能。
❌
❌