阅读视图

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

flutterAppBar之SystemUiOverlayStyle源码解析(一)

在 Flutter 中,SystemUiOverlayStyle 是用来控制系统 UI 元素(比如状态栏和导航栏)的外观的。通过它,你可以定制状态栏的颜色、图标亮度,导航栏的外观等。

Brightness

/// 描述主题或色彩调色板的对比度。
enum Brightness {
  /// 颜色较深,通常需要使用浅色文本才能达到良好的可读对比度。
  ///
  /// 例如,颜色可能是深灰色,需要使用白色文本。
  dark,

  /// 颜色较浅,通常需要使用深色文本才能达到良好的可读对比度。
  ///
  /// 例如,颜色可能是明亮的白色,需要使用黑色文本。
  light,
}

SystemUiOverlayStyle

/// 指定系统覆盖层样式的首选项。
///
/// 通过 [AppBar.systemOverlayStyle] 声明性地设置系统覆盖层的样式,
/// 通过 [SystemChrome.setSystemUIOverlayStyle] 命令式地设置系统覆盖层的样式。
@immutable
class SystemUiOverlayStyle {
  /// 创建一个新的 [SystemUiOverlayStyle] 实例。
  const SystemUiOverlayStyle({
    this.systemNavigationBarColor, // 底部导航栏颜色
    this.systemNavigationBarDividerColor, // 底部导航栏分隔线颜色
    this.systemNavigationBarIconBrightness, // 导航栏图标亮度
    this.systemNavigationBarContrastEnforced, // 是否强制执行导航栏对比度
    this.statusBarColor, // 状态栏颜色
    this.statusBarBrightness, // 状态栏亮度
    this.statusBarIconBrightness, // 状态栏图标亮度
    this.systemStatusBarContrastEnforced, // 是否强制执行状态栏对比度
  });
}

systemNavigationBarColor、systemNavigationBarDividerColor、systemNavigationBarIconBrightness、systemNavigationBarContrastEnforced

/// 系统底部导航栏的颜色。
///
/// 仅在 Android O 及更高版本中生效。
final Color? systemNavigationBarColor;

/// 系统底部导航栏和应用内容之间的分隔线颜色。
///
/// 仅在 Android P 及更高版本中生效。
final Color? systemNavigationBarDividerColor;

/// 系统导航栏图标的亮度。
///
/// 仅在 Android O 及更高版本中生效。
/// 当设置为 [Brightness.light] 时,系统导航栏图标为浅色。
/// 当设置为 [Brightness.dark] 时,系统导航栏图标为深色。
final Brightness? systemNavigationBarIconBrightness;

/// 在设置透明导航栏时覆盖对比度强制执行。
///
/// 在 SDK 29+ 或 Android 10 及更高版本中设置透明导航栏时,
/// 可能会在按钮导航栏后面应用一个半透明的背景遮罩,
/// 以确保与按钮和应用背景之间的对比度。
///
/// SDK 28- 或 Android P 及更低版本不会应用这个背景遮罩。
///
/// 将此设置为 false 将覆盖默认的背景遮罩。
///
/// 另请参阅:
///
///   * [SystemUiOverlayStyle.systemNavigationBarColor],在透明导航栏时,
///     此设置会覆盖默认的对比度策略。
final bool? systemNavigationBarContrastEnforced;

statusBarColor

/// 顶部状态栏的颜色。
///
/// 仅在 Android M 及更高版本中生效。
final Color? statusBarColor;

  • statusBarColor:用于设置顶部状态栏的颜色。该属性仅在 Android M(Android 6.0)及更高版本中生效。

statusBarBrightness

/// 顶部状态栏的亮度。
///
/// 仅在 iOS 中生效。
final Brightness? statusBarBrightness;

statusBarIconBrightness

/// 顶部状态栏图标的亮度。
///
/// 仅在 Android M 及更高版本中生效。
final Brightness? statusBarIconBrightness;

  • statusBarIconBrightness:用于设置顶部状态栏图标的亮度。该属性仅在 Android M(Android 6.0)及更高版本中生效。Brightness.light 表示浅色图标,Brightness.dark 表示深色图标。

systemStatusBarContrastEnforced

/// 在设置透明状态栏时覆盖对比度强制执行。
///
/// 在 SDK 29+ 或 Android 10 及更高版本中设置透明状态栏时,
/// 可能会应用一个半透明的背景遮罩,以确保图标与应用背景之间的对比度。
///
/// 在 SDK 28- 或 Android P 及更低版本中,不会应用这个背景遮罩。
///
/// 将此设置为 false 将覆盖默认的背景遮罩。
///
/// 另请参阅:
///
///   * [SystemUiOverlayStyle.statusBarColor],在透明状态栏时,
///     此设置会覆盖默认的对比度策略。
final bool? systemStatusBarContrastEnforced;

systemStatusBarContrastEnforced:用于在设置透明状态栏时,是否强制应用对比度遮罩。这个属性可以确保状态栏图标和背景之间有足够的对比度,以提高可读性。

  • 在 Android 10 及更高版本(SDK 29+)中,如果设置透明状态栏,会自动应用半透明背景遮罩。
  • 在 Android P 及更低版本中不会应用背景遮罩。
  • 将此属性设置为 false 可以禁用这种对比度增强策略,使用默认行为。

light

/// 系统覆盖层应使用浅色绘制,适用于背景为深色的应用程序。
/// 该样式适用于需要浅色图标和深色背景的场景,例如深色背景的应用。
static const SystemUiOverlayStyle light = SystemUiOverlayStyle(
  // 设置底部导航栏的背景颜色为黑色
  systemNavigationBarColor: Color(0xFF000000),
  
  // 设置底部导航栏图标的亮度为浅色(适合黑色背景)
  systemNavigationBarIconBrightness: Brightness.light,
  
  // 设置顶部状态栏图标的亮度为浅色(适合深色背景)
  statusBarIconBrightness: Brightness.light, 
  
  // 设置顶部状态栏背景颜色为深色,以便浅色图标更加突出
  statusBarBrightness: Brightness.dark,
);

dark

/// 系统覆盖层应使用深色绘制,适用于背景为浅色的应用程序。
/// 该样式适用于需要深色图标和浅色背景的场景,例如浅色背景的应用。
static const SystemUiOverlayStyle dark = SystemUiOverlayStyle(
  // 设置底部导航栏的背景颜色为黑色
  systemNavigationBarColor: Color(0xFF000000),
  
  // 设置底部导航栏图标的亮度为浅色(适合黑色背景)
  systemNavigationBarIconBrightness: Brightness.light,
  
  // 设置顶部状态栏图标的亮度为深色(适合浅色背景)
  statusBarIconBrightness: Brightness.dark, 
  
  // 设置顶部状态栏背景颜色为浅色,以便深色图标更加突出
  statusBarBrightness: Brightness.light,
);

让Trae来试试大佬写的Vercel Mcp,轻松创建和管理Vercel项目

前言

如果你经常使用Vercel进行项目部署,你可能会遇到一些不便。

Vercel是一个强大的无服务器平台,支持快速部署和自动缩放,但它也有一些局限性。例如,Trae(假设是一个自动化部署工具或CI/CD平台)是静默部署的,这意味着你无法直接在Trae中查看Vercel上的项目状态、部署情况,以及环境变量的配置。

每次需要查看或修改环境变量时,你都必须切换到浏览器,登录Vercel网站,手动进行操作。这不仅浪费时间,还降低了工作效率。

这个时候就很浪费时间,所以有没有一款Mcp,这样我就可以在Trae里面进行提问,就可以知道我的项目以及相关的环境变量是怎么样的情况,在一天逛掘金的时候,无意间看到一个大佬的文章刚好可以解决我现在的痛点

痛点分析

1. 无法直接查看部署状态

  • 问题:在Trae中完成部署后,你无法直接看到Vercel上的部署是否成功,部署日志是什么样的。
  • 影响:你需要手动登录Vercel网站,查看部署详情,这增加了操作步骤和时间成本。

2. 环境变量管理不便

  • 问题:创建、修改或删除环境变量都需要在Vercel网站上手工操作。
  • 影响:每次修改环境变量都需要切换到浏览器,这不仅麻烦,还容易出错,尤其是在需要频繁调整配置的情况下。

3. 缺乏集成性

  • 问题:Trae和Vercel之间缺乏直接的集成。
  • 影响:你无法在一个平台上完成所有操作,需要在多个工具之间切换,这不仅降低了效率,还增加了出错的可能性。

首先是拉起大佬的mcp代码,然后执行一下打包构建命令

git clone https://github.com/XiaYeAI/vercel-mcp-server 
cd vercel-mcp-server 
npm install 
npm run build

然后是去到vercel的account setting的tokens创建一个token,可以创建一个无限期的token,这样就可以一直用了 image.png 然后在Trae手动添加一个Mcp,具体如下 image.png 使用node,刚刚打包构建的index.js就可以使用上

{
  "mcpServers": {
    "vercel": {
      "command": "node",
      "args": [
        "E:\\GIT_CODE\\mcp\\vercel-mcp-server\\dist\\index.js"
      ],
      "env": {
        "VERCEL_TOKEN": "your_vercel_api_token_here",
        "LOG_LEVEL": "info"
      }
    }
  }
}

然后在Trae里面创建一个智能体,用来提问Vercel,让他去获取对应的Vercel项目信息,以及创建环境变量等工作

image.png

最后就是开始提问vercel mcp,我是让他帮我的项目添加一个环境变量,等了几秒后,Trae帮我创建好了 image.png

然后我们来到vercel的官网,然后验证一下是不是真的添加成功,避免Vercel mcp出现幻觉,太强了,Vercel mcp他添加成功了 image.png

不会写Mcp server,那就让Trae写吧

前言

在上一篇文章中,Trae帮助我们深入理解了MCP(Model Context Protocol)是什么,以及MCP标准输入输出的格式。MCP是一种轻量级的通信协议,通过标准输入输出(stdin/stdout)进行数据交换,使用JSON格式来传递消息。

这种协议非常适合构建微服务,因为它简单、高效,且易于实现。

在本文中,我们将进一步深入,使用官方SDK来创建一个支持文件夹增删改查功能的MCP服务。我们将通过一个具体的实例,展示如何从零开始构建这样一个服务,并确保它能够与MCP协议无缝对接。

首先先让Trae使用官方的sdk帮我创建一个可以crud文件夹的mcp服务,使用nodejs来编写这个Mcp服务

image.png

可以看到所谓的官方的sdk是这个@modelcontextprotocol/sdk,如果对这个不了解的话,可以自己去npm上面看,这里可以简单的理解为,有了这个官方的sdk,我们的mcp就可以做很多事情

image.png

2.1 初始化项目

首先,我们需要创建一个新的Node.js项目并初始化。

Trae在你的工作目录中运行以下命令:

mkdir folder-mcp--server
cd folder-mcp--server
npm init -y

这将创建一个名为folder-mcp--server的文件夹,并在其中初始化一个新的Node.js项目。

2.2 安装官方SDK

接下来,我们需要安装@modelcontextprotocol/sdk

npm install @modelcontextprotocol/sdk

安装完成后,你可以在项目的node_modules文件夹中找到@modelcontextprotocol/sdk

2.3 编写服务代码

Trae在项目src目录中创建一个名为index.js的文件,并添加以下代码:

#!/usr/bin/env node
  
import { Server } from '@modelcontextprotocol/sdk/server/index.js';

import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

import {

  CallToolRequestSchema,

  ListToolsRequestSchema,

} from '@modelcontextprotocol/sdk/types.js';

import { promises as fs, constants as fsConstants } from 'fs';

import path from 'path';

import { fileURLToPath } from 'url';

  

创建一个mcp服务

const server = new Server(
  {
    name: 'folder-mcp-server',
    version: '1.0.0',
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

辅助函数:检查路径是否存在

async function pathExists(targetPath: string): Promise<boolean> {
  try {
    await fs.access(targetPath);
    return true;
  } catch {
    return false;
  }
}

辅助函数:计算文件夹大小

async function getFolderSize(folderPath: string): Promise<number> {
  let totalSize = 0;
  try {
    const stats = await fs.stat(folderPath);
    if (!stats.isDirectory()) {
      return stats.size;
    }
  
    const items = await fs.readdir(folderPath, { withFileTypes: true });
    for (const item of items) {
      const itemPath = path.join(folderPath, item.name);
      if (item.isDirectory()) {
        totalSize += await getFolderSize(itemPath);
      } else {
        const itemStats = await fs.stat(itemPath);
        totalSize += itemStats.size;
      }
    }
  } catch (error) {
    console.error(`Error calculating folder size: ${error}`);
  }
  return totalSize;
}

辅助函数:格式化文件大小

function formatBytes(bytes: number, decimals = 2): string {
  if (bytes === 0) return '0 Bytes';
  const k = 1024;
  const dm = decimals < 0 ? 0 : decimals;
  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}

辅助函数:递归复制文件夹

async function copyFolder(source: string, destination: string): Promise<void> {
  await fs.mkdir(destination, { recursive: true });
  const items = await fs.readdir(source, { withFileTypes: true });
  for (const item of items) {
    const sourcePath = path.join(source, item.name);
    const destinationPath = path.join(destination, item.name);
    if (item.isDirectory()) {
      await copyFolder(sourcePath, destinationPath);
    } else {
      await fs.copyFile(sourcePath, destinationPath);
    }
  }
}  

注册工具

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: 'create_folder',
        description: '创建新文件夹',
        inputSchema: {
          type: 'object',
          properties: {
            path: {
              type: 'string',
              description: '要创建的文件夹路径',
            },
            recursive: {
              type: 'boolean',
              description: '是否递归创建父目录',
              default: true,
            },
          },
          required: ['path'],
        },
      },
      {
        name: 'delete_folder',
        description: '删除文件夹',
        inputSchema: {
          type: 'object',
          properties: {
            path: {
              type: 'string',
              description: '要删除的文件夹路径',
            },
            force: {
              type: 'boolean',
              description: '是否强制删除非空文件夹',
              default: false,
            },
          },
          required: ['path'],
        },
      },
      {
        name: 'rename_folder',
        description: '重命名或移动文件夹',
        inputSchema: {
          type: 'object',
          properties: {
            oldPath: {
              type: 'string',
              description: '原文件夹路径',
            },
            newPath: {
              type: 'string',
              description: '新文件夹路径',
            },
          },
          required: ['oldPath', 'newPath'],
        },
      },
      {
        name: 'list_folder',
        description: '列出文件夹内容',
        inputSchema: {
          type: 'object',
          properties: {
            path: {
              type: 'string',
              description: '要列出的文件夹路径',
            },
            recursive: {
              type: 'boolean',
              description: '是否递归列出子文件夹内容',
              default: false,
            },
          },
          required: ['path'],
        },
      },
  }

});

启动服务器的方法

async function main() {

  const transport = new StdioServerTransport();

  await server.connect(transport);

  console.error('文件夹管理MCP服务器已启动');

}

  


main().catch((error) => {

  console.error('服务器启动失败:', error);

  process.exit(1);

});

然后让Trae帮我们创建一个可以测试的js脚本

image.png

执行node simple-test.js ,可以看到控制台,输出打印和文件夹创建test-demo文件夹

image.png

执行一下删除文件夹,把刚刚新建的也删除了

image.png

Jenkins部署前端项目实战方案

概述 在目前工程化项目中,项目上线必定经过代码编写---》打包---》部署的流程,如果没有一个自动化部署的方案,按照传统项目开发方式,我们只有每次项目开发完后,手动打包项目,然后登录服务器,将我们本地

基于TinyMce富文本编辑器的客服自研知识库的技术探索和实践|得物技术

一、 背景

客服知识库是一个集中管理和存储与客服相关的信息和资源的系统,在自研知识库上线之前,得物采用的承接工具为第三方知识库系统。伴随着业务的发展,知识的维护体量、下游系统的使用面临的问题愈发明显,而当前的第三方采购系统,已经较难满足内部系统间高效协作的诉求,基于以上业务诉求,我们自研了一套客服知识库。

二、富文本编辑器的选型

以下是经过调研后列出的多款富文本编辑器综合对比情况:

2.1 编辑器的选择

  • 自研知识库要求富文本编辑器具备表格的编辑能力,由于Quill不支持表格编辑能力(借助表格插件可以实现该能力,但经过实际验证,插件提供的表格编辑能力不够丰富,使用体验也较差),被首先被排除。
  • wangEditor体验过程中发现标题和列表(有序、无序)列表两个功能互斥,体验不太好,而这两个功能都是自研知识库刚需功能,也被排除。
  • Lexical是facebook推出的一款编辑器,虽功能很丰富,但相较于CKEditorTinyMCE,文档不够完善,社区活跃性较低,插件不成熟,故优先选择CKEditorTinyMCE

CKEditorTinyMCE经过对比,由于当前正在使用的第三方知识库采用的是TinyMCE编辑器,选择TinyMC在格式兼容上会更友好,对新老知识库的迁移上更有利。且TinyMCE在功能丰富度上略占优势,故最终选择TinyMCE作为本系统文档知识库的编辑器

2.2 TinyMce编辑器模式的选择

经典模式(默认模式)

基于表单,使用表单某字段填充内容,编辑器始终作为表单的一部分。内部采用了iframe沙箱隔离,将编辑内容与页面进行隔离。

※ 优势

样式隔离好。

※ 劣势

由于使用iframe,性能会差点,尤其对于多实例编辑器。

内联模式(沉浸模式)

将编辑视图与阅读视图合二为一,当其被点击后,元素才会被编辑器替换。而不是编辑器始终可见,不能作为表单项使用。内容会从它嵌入的页面继承CSS样式表。

※ 优势

性能相对较好,页面的编辑视图与阅读视图合二为一,提供了无缝的体验,实现了真正的所见即所得。

※ 劣势

样式容易受到页面样式的影响。

三、系统总览

3.1 知识创建链路

3.2 知识采编

结构化段落

为了对知识文档做更细颗粒度的解析,客服知识库采用了结构化段落的设计思想,每个段落都会有一个唯一标志 ,且支持对文档的每个段落单独设置标签,这样在后期的知识检索、分类时,便可以精确定位到知识文档的具体段落,如下图所示。

知识文档编辑页面

3.3 应用场景

客服知识库的主要应用场景如下:

知识检索

基于传统的ES检索能力,用于知识库的检索,检索要使用的知识,且可以直接在工作台打开对应的知识并浏览,并可以定位、滚动到具体的知识段落。同时还会高亮显示知识文档中匹配到的搜索关键字

智能问答(基于大模型能力和知识库底层数据的训练)

※ RAG出话

辅助客服了解用户的真实意图,可用于客服作业时的参考。

原理阐述: RAG是一种结合了检索和生成技术的人工智能系统。它是大型语言模型的一种,但特别强调检索和生成的结合。RAG的最主要的工作流程包括:

  • 检索阶段:系统会根据用户的查询,从客服知识库中检索出相关信息。这些信息可能包括知识库内容、订单信息和商品信息等
  • 生成阶段:RAG使用检索到的信息来增强其生成过程。这意味着,生成模型在生成文本时,会考虑到检索到的相关信息,以生成更准确、更相关的回答。你可以直接将搜索到的内容返回给用户也可以通过LLM模型结合后生成给用户。

※ 答案推荐

可以根据用户搜索内容、上下文场景(如订单信息、商品信息)辅助客服更高效的获取答案。

流程示意:

※ 联网搜索

当RAG出话由于拒识没有结果时,便尝试进行联网搜索给出结果,可作为RAG能力失效后的补充能力。

原理阐述: 底层使用了第三方提供的联网问答Agent服务。在进行联网搜索之前,会对用户的查询信息进行风控校验,风控校验通过后,再进行 【指定意图清单】过滤,仅对符合意图的查询才可以进行联网搜索。

四、问题和解决方案

4.1 解决图片迁移问题

背景

在新老知识迁移的过程中,由于老知识库中的图片链接的域名是老知识库的域名,必须要有老知识库的登录台信息,才能在新知识库中访问并渲染。为了解决这个问题,我们对用户粘贴的动作进行了监听,并对复制内容的图片链接进行了替换。

时序图

核心逻辑

/**
 * 替换编辑器中的图片URL
 * @param content
 * @param editor 编辑器实例
 * @returns 替换后的内容
 */
export const replaceImgUrlOfEditor = (content, editor) => {
  // 提取出老知识中的图片访问链接
  const oldImgUrls = extractImgSrc(content);
  // 调用接口获取替换后的图片访问链接
  const newImageUrls = await service.getNewImageUrl(oldImgUrls);
  // 将老知识库的图片链接替换成新的可访问的链接
  newContent = replaceImgSrc(newContent, replacedUrls.imgUrls);
  // 使用新的数据更新编辑器视图
  editor.updateView(newContent);
};

4.2 解决加载大量图片带来的页面卡顿问题

背景

知识库内含有大量的图片,当我们打开一篇知识时,系统往往因为在短时间内加载、渲染大量的图片而陷入卡顿当中,无法对页面进行其他操作。这个问题在老知识库中尤为严重,也是研发新知识库过程中我们需要重点解决的问题。

解决方案

我们对图片进行了懒加载处理:当打开一篇知识时,只加载和渲染可见视图以内的图片,剩余的图片只有滚动到可见视图内才开始加载、渲染。

由于我们要渲染的内容的原始数据是一段html字符串,一篇知识文档的最小可渲染单元是段落(结构化段落),而一个段落的内容大小事先是不知道的,因此传统的滚动加载方式在这里并不适用:比如当滚动到需要加载下一段落的位置时,如果该段落的内容特别大且包含较多图片时,依然会存在卡顿的现象。

我们采用正则匹配的方式,识别出知识文档的html中所有的  标签(将文档的html视作一段字符串),并给  标签插入 loading="lazy" 的属性,具备该属性的图片在到达可视视图内的时候才会加载图片资源并渲染,从而实现懒加载的效果,大大节省了知识文档初次渲染的性能开销。并且该过程处理的是渲染知识文档前的html字符串,而非真实的dom操作,所以不会带来重绘、重排等性能问题。

知识文档渲染的完整链路

4.3 模板缩略图

背景

在知识模板列表页或者在创建新知识选择模板时,需要展示模板内容的缩略图,由于每个模板内容都不一样,同时缩略图中需要可以看到该模板靠前的内容,以便用户除了依靠模板标题之外还可以依靠一部分的模板内容选择合适的模板。

解决方案

在保存知识模板前,通过截屏的方式保存一个模板的截图,上传截图到cdn并保存cdn链接,再对截图进行一定的缩放调整,即可作为模板的缩略图。

时序图

实际效果

模板列表中缩略图展示效果:

新建知识时缩略图展示效果:

4.4 全局查找/替换

背景

知识库采用了结构化段落的设计思想,技术实现上,每个段落都是一个独立的编辑器实例。这样实现带来一个弊端:使用编辑器的搜索和替换功能时,查找范围仅限于当前聚焦的编辑器,无法同时对所有编辑器进行查找和替换,增加了业务方的编辑费力度。

解决方案

调研、扩展编辑器的查找/替换插件的源码,调度和联动多编辑器的查找/替换API从而实现全局范围内的查找/替换。

※ 插件源码剖析

通过对插件源码的分析,我们发现插件的查找/替换功能是基于4个基本的API实现的: find 、 replace 、 next 、 prev 、 done 。

※ 设计思路

通过在多个编辑器中加入一个调度器来控制编辑器之间的接力从而实现全局的查找/替换。同时扩展插件的API辅助调度器在多编辑器之间进行调度

※ 插件源码API扩展

  1. hasMatched: 判断当前编辑器是否匹配到关键字。
  2. hasReachTop:判断当前编辑器是否已到达所查找关键字的最前一个。
  3. hasReachBottom:判断当前编辑器是否已到达所查找关键字的最后一个。
  4. current: 滚动到编辑器当前匹配到的关键字的位置。
  5. clearCurrentSelection: 对编辑器当前匹配到的关键字取消高亮效果。

UI替换

屏蔽插件自带的查找/替换的弹窗,实现一个支持全局操作的查找/替换的弹窗:使用了react-rnd组件库实现可拖拽弹窗,如下图所示:

「查找」

※ 期望效果

当用户输入关键字并点击查找时,需要在文档中(所有编辑器中)标记出(加上特定的背景色)所有匹配到该关键字的文本,并高亮显示出第一个匹配文本。

※ 流程图

「下一个」

※ 期望效果

当用户点击「下一个」时,需要高亮显示下一个匹配结果并滚动到该匹配结果的位置。

※ 流程图

五、总结

在新版客服知识库的研发和落地过程中,我们基于TinyMce富文本编辑器的基础上,进行了功能扩展和定制。这期间既有参考过同类产品(飞书文档、语雀)的方案,也有根据实际应用场景进行了创新。截止目前已完成1000+老知识库的顺利迁移,系统稳定运行。

自研过程中我们解决了老版知识库系统的卡顿和无法满足定制化需求的问题。并在这些基本需求得到满足的情况下,通过优化交互方式和知识文档的加载、渲染性能等方式进一步提升了使用体验

后续我们会结合用户的反馈和实际使用需求进一步优化和扩展客服知识库的功能,也欢迎有同样应用场景的同学一起交流想法和意见。

往期回顾

1.AI质量专项报告自动分析生成|得物技术

2.Rust 性能提升“最后一公里”:详解 Profiling 瓶颈定位与优化|得物技术

3.Valkey 单点性能比肩 Redis 集群了?Valkey8.0 新特性分析|得物技术

4.Java SPI机制初探|得物技术

5.社区搜索离线回溯系统设计:架构、挑战与性能优化|得物技术



文 / 煜宸

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

面试官:一个接口使用postman这些测试很快,但是页面加载很慢怎么回事 😤😤😤

最近在出一个前端的体系课程,里面的内容非常详细,如果你感兴趣,可以加我 v 进行联系 yunmz777:

image.png

浪费你几秒钟时间,内容正式开始

在 Postman 里同一个接口“飞快”,但放到网页里就“很慢”。本质上是两类耗时叠加不同:

  • Postman 只覆盖“发请求 → 后端处理 → 回包”。
  • 浏览器页面除了这段,还要承担预检、认证、下载、解析、计算、渲染、第三方脚本等一堆额外成本。

接下来的内容我们将判断慢在哪里 → 常见原因 → 如何定位 → 对症优化”来展示一个系统化排查思路。

先判定:慢在网络/后端,还是慢在前端/渲染?

我们可以先打开浏览器的 DevTools,在 Network 里查看请求的 Timing 阶段:如果 TTFB 明显偏大,多半是后端或网络延迟;如果 Content Download 阶段耗时长,往往是响应体太大、压缩缺失或带宽不足;而如果 Finish 时间远大于 TTFB 加 Download,则通常是前端在解析或渲染时耗时。

接着可以用 Performance 面板录制,若看到大量超过 50ms 的 Long Task 或主线程被 JS 占满,就说明是前端计算问题。

总结经验:Postman 调用接口快而页面加载慢,常见原因是浏览器额外开销,例如 CORS 预检、Cookie 过大、JS 计算和第三方脚本渲染,这类情况占大多数。

常见导致“Postman 快、页面慢”的 12 个具体原因

在排查这类问题时,哪些因素最可能导致页面变慢?我根据经验把常见原因按出现频率和影响度排序,逐一展开说明,方便我们对比不同的情况。

CORS 预检(OPTIONS)多一跳 RTT

浏览器跨域时,如果使用了自定义请求头或非简单 Content-Type,就会多出一次 OPTIONS 预检,增加一次 RTT。

解决方案是减少自定义头、改用“简单请求”(GET/HEAD/POST + application/x-www-form-urlencoded/multipart/form-data/text/plain)、或在服务端加 Access-Control-Max-Age 缓存预检、同域反向代理。

Cookie 过大(只在浏览器自动携带)

浏览器会自动携带域下所有 Cookie,请求头因此膨胀,每次请求都要上传无用数据,而 Postman 默认不带这些。

优化方案是精简 Cookie,缩小作用域(Path/子域),将非会话信息移到 Authorization 头。

前端一次性处理大 JSON

数 MB 的 JSON 在浏览器端需要 JSON.parse、拷贝和排序聚合,会阻塞主线程;Postman 并不负责渲染。

优化方案是分页或字段裁剪,采用流式/增量渲染,将重计算放入 Web Worker,并使用虚拟列表。

串行 / N+1 请求

前端把接口串行调用,或为每个列表项单独请求,容易放大延迟。

优化方案是请求并行化,使用批量接口或后端聚合,减少瀑布式请求。

第三方脚本 / SDK 阻塞

埋点、广告、地图、可视化库等同步或大体积脚本会占用主线程和网络。

优化方案是脚本使用 defer/async,按需或懒加载,合理拆分和延迟非关键模块。

未压缩或压缩失配

响应缺少 gzip/br 压缩,或代理配置错误导致压缩失效,会拖慢下载速度。

优化方案是开启 gzip/br 压缩,确认浏览器 Accept-Encoding 与响应头配置一致。

缓存策略不当

如果响应携带 Cache-Control: no-store,就会每次都打后端;Service Worker 缓存策略错误也可能拖慢加载。

优化方案是合理使用 ETag/If-None-Matchmax-agestale-while-revalidate,必要时重置或暂时注销 Service Worker。

环境 / 路由差异

浏览器请求可能经过 CDN、网关或公司代理,而 Postman 是直连;或者 baseURL、DNS 不一致。

优化方案是比对请求目标地址、Host、Via/X-Forwarded-For 头,检查 DNS 与代理配置。

HTTP/2/3 未启用或连接竞争

使用 HTTP/1.1 时,多请求会遇到队头阻塞,导致加载变慢。

优化方案是启用 HTTP/2/3,在关键域名上增加 <link rel="preconnect"> 提前建立连接。

前端状态管理与渲染策略

频繁 setState、低效 diff、大表格无虚拟化,或渲染阶段做复杂计算都会拖慢页面。

优化方案是使用 memoization、批量更新、虚拟滚动,将计算逻辑移出渲染。

错误重试 / 超时

请求失败后隐式重试,或超时阈值过大,也会延长整体耗时。

优化方案是在 Network 面板确认是否有重复请求,调整重试与超时策略。

资源型瓶颈(图片 / 字体 / 视频)

图片原图过大、无懒加载,或字体阻塞渲染,都会造成首屏卡顿。

优化方案是压缩和多尺寸适配,使用 WebP/AVIF 格式,loading="lazy" 懒加载,字体加 display=swap,关键资源用 preload

如何定位问题

用 curl 对比请求耗时

在 DevTools Network 面板里找到目标请求,选择 “Copy as cURL”,然后在终端里执行并统计各阶段耗时:

curl 'https://juejin.cn/post/7538806888806481961' \
  -H 'Authorization: Bearer xxx' \
  -H 'Content-Type: application/json' \
  -w '\nDNS:%{time_namelookup} TCP:%{time_connect} TLS:%{time_appconnect} \
TTFB:%{time_starttransfer} TOTAL:%{time_total}\n' \
  -o /dev/null -s

通过对比 curl 的结果和 Postman 的表现,可以快速判断瓶颈位置:

  • 如果 curl 的 TTFB/TOTAL 和 Postman 一样很快,而页面依然慢,大概率问题出在 前端解析或浏览器特有开销。

  • 如果 curl 本身也慢,那就说明延迟来自 后端处理或网络链路。

最小可复现页:剔除 UI 干扰

在排查页面性能时,可以先搭建一个最小可复现页,只做一次请求并把结果简单输出,用来测量 fetchJSON.parse 和渲染三个阶段的耗时:

<!DOCTYPE html>
<meta charset="utf-8" />
<body>
  <script>
    const url = "https://api.example.com/xxx";

    console.time("fetch");
    fetch(url, { credentials: "include" }) // 若依赖 Cookie,记得加 include
      .then((r) => {
        console.timeEnd("fetch");
        console.time("json");
        return r.json();
      })
      .then((data) => {
        console.timeEnd("json");
        console.time("render");
        const pre = document.createElement("pre");
        pre.textContent = JSON.stringify(data.slice?.(0, 50) ?? data, null, 2);
        document.body.appendChild(pre);
        console.timeEnd("render");
      })
      .catch((e) => console.error(e));
  </script>
</body>

通过这种方式,可以快速判断问题属于哪一类:

  • fetch 慢 → 网络、后端延迟,或预检请求/请求头过大。

  • json 慢 → 数据体积过大或结构复杂,解析开销高。

  • render 慢 → UI 渲染或 DOM 操作成为瓶颈。

观察长任务(前端计算/阻塞)

在浏览器里,可以利用 PerformanceObserver 来捕捉 长任务(Long Task)。长任务通常是指执行时间超过 50ms 的 JavaScript 代码片段,它们会阻塞主线程,直接导致页面卡顿或交互延迟。

示例代码:

new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    console.log("Long Task:", e.duration.toFixed(1), "ms", e);
  }
}).observe({ entryTypes: ["longtask"] });

通过这段脚本,你可以在控制台里实时看到哪些操作耗时过长,从而判断瓶颈是否在前端计算逻辑(例如 JSON 解析、复杂循环、DOM 操作或第三方库执行)。

检查是否触发 CORS 预检

在 Network 面板中查看请求是否多出了一条 OPTIONS 请求,如果有,就说明触发了 CORS 预检。此时要确认响应里是否正确返回并允许缓存,例如:

Access-Control-Allow-Origin: https://your.site
Access-Control-Allow-Headers: Authorization, Content-Type, ...
Access-Control-Allow-Methods: GET, POST, ...
Access-Control-Max-Age: 86400

如果 Access-Control-Max-Age 缺失或过小,浏览器会频繁重复预检,从而增加延迟。

对比请求头(Postman vs 浏览器)

很多情况下,Postman 请求快而页面请求慢,差异就藏在请求头里。重点检查以下字段:

  • Origin:是否跨域触发预检。

  • Cookie:浏览器会自动带,Postman 默认不带。

  • Authorization:身份验证方式是否一致。

  • Accept / Accept-Encoding:是否导致返回内容差异(如压缩失效)。

  • Content-Type / 自定义头:是否触发预检。

  • User-Agent:部分网关会针对不同 UA 做限流或鉴权策略。

Service Worker 与缓存策略

如果页面使用了 Service Worker,它可能会带来额外的缓存逻辑或回源延迟。排查步骤:

  1. 在 Application 面板里先尝试 Unregister 掉 Service Worker,再测试一次请求耗时。

  2. 对比关键响应头:

    • Cache-Control:是否合理缓存。
    • ETag / If-None-Match:是否命中缓存。
    • Vary:是否导致缓存错失。
    • Content-Encoding:是否启用了 gzip/br 压缩。
    • Server-Timing:是否能看到服务端各阶段耗时。

对症优化清单(按层次)

网络/协议/CDN

在网络和协议层面,首先要确保启用了 HTTP/2 或 HTTP/3 以及 TLS 会话复用,这样可以减少连接建立和队头阻塞带来的延迟。对于关键的接口域名,可以提前建连,例如在页面里加上:

<link rel="preconnect" href="https://api.example.com" />

同时,要开启 gzip 或 br 压缩,并确认中间的代理不会剥离压缩,否则大响应体会严重拖慢下载。最后,合理利用 CDN 就近与缓存策略,例如:

Cache-Control: public, max-age=600, stale-while-revalidate=60

这样浏览器就能优先使用本地或边缘缓存,大大减少重复请求和等待时间。

CORS / 请求形态

在跨域请求时,如果带了自定义请求头或者使用了不属于“简单请求”的 Content-Type,就会触发 预检请求(OPTIONS),额外增加一次 RTT。能避免的情况下,应尽量使用“简单请求”,比如:

fetch("https://api.example.com/data", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: "id=123&name=test",
});

如果业务确实需要复杂头部,也要在服务端配置合理的 Access-Control-Max-Age,让浏览器缓存预检结果,减少重复开销:

Access-Control-Allow-Origin: https://your.site
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

此外,避免请求头里自动携带大量 Cookie,认证信息建议放到 Authorization 头中,例如:

Authorization: Bearer <token>

这样能显著降低请求体积和重复传输。

API 设计

接口本身的设计也直接影响性能。如果一次接口返回数 MB 的 JSON,前端解析必然会卡顿。最佳实践是分页、字段裁剪,只返回必要数据。例如:

GET /api/orders?page=1&pageSize=20&fields=id,price,status

对于多请求场景,可以通过 批量接口 来避免 N+1 的问题:

POST /api/orders/batch
{
  "ids": [1001, 1002, 1003]
}

同时,建议在响应头里加上 Server-Timing,这样可以直观看到后端每个阶段的耗时,方便端到端定位:

Server-Timing: db;dur=53, cache;desc="hit", app;dur=120

前端实现

在前端层面,最常见的性能问题就是资源阻塞和渲染开销过大。例如大型组件或第三方库(如地图、可视化框架)不应在首屏同步加载,而是采用 懒加载:

import("echarts").then((echarts) => {
  // 按需加载使用
});

对于复杂计算,如排序、聚合或格式化数据,应放到 Web Worker 里执行,避免阻塞主线程:

const worker = new Worker("worker.js");
worker.postMessage(largeData);
worker.onmessage = (e) => render(e.data);

渲染层面,大量数据列表要用 虚拟滚动,避免一次性绘制成千上万条 DOM 节点。比如 React 可以用 react-window 来优化。与此同时,可以用 骨架屏 或占位符来提升用户的感知速度:

<div class="skeleton-card"></div>

最后,不要滥用深拷贝,比如 JSON.parse(JSON.stringify(...)),这种写法对大对象会带来严重性能问题,应使用更轻量的对象拷贝方式。

总结

排查接口在页面加载慢的问题,可以先看 TTFB 和 Download 阶段:TTFB 高多半是后端或网络瓶颈,Download 高则可能是响应体过大或压缩缺失。若两者都正常但页面依旧卡顿,则通常是前端解析、渲染或第三方脚本拖慢了速度。

除此之外,还要留意 CORS 预检、请求头膨胀(Cookie、自定义头) 以及 Service Worker 缓存逻辑,这些都是浏览器特有的额外开销。整体来说,Postman 快而页面慢,大多数情况都能归因到这些环节。

分享一个超级炫酷的字体爆炸效果!

写在开头

01.gif

之前一直学习glsl不可避免的也接触到了three.js,本次给大家分享一个刚学到的字体形变爆炸效果

前期准备


function preload() {

const loader = new FontLoader();
loader.load("Archivo_Black_Regular.json", (font) => {
init(font);
});

}

function init(font) {

}

window.onload = preload;

首先我们需要加载一个Three.js专用的字体格式:

// 格式如下
{
  "glyphs": {
    "0": {
      "ha": 926,
      "x_min": 57,
      "x_max": 869,
      "o": "m 464 972 q 778 845 688 972 q 869 478 869 718 q 778 110 869 238 q 464 -17 688 -17 q 148 110 239 -17 q 57 478 57 238 q 148 845 57 718 q 464 972 239 972 m 464 785 q 361 729 392 785 q 331 547 331 674 l 331 410 q 361 226 331 282 q 464 171 392 171 q 565 226 536 171 q 594 410 594 282 l 594 547 q 565 730 594 675 q 464 785 536 785 z "
    },
    "1": {
      "ha": 926,
      "x_min": 146,
      "x_max": 867,
      "o": "m 867 214 l 867 0 l 146 0 l 146 214 l 372 214 l 372 646 l 146 646 l 146 807 q 354 856 239 813 q 556 957 469 900 l 649 957 l 649 214 l 867 214 z "
    }
    ...
  }
}

关键信息包括:

  • o 字段:SVG路径格式的字符轮廓
  • ha:字符宽度(horizontal advance)
  • 边界框:用于布局计算
  • 分辨率:单位转换参考

为什么要用这种格式是因为TextGeometry 需要创建3D文字几何体,不是简单的2D文本。它需要:

  • 字符的精确轮廓路径
  • 每个字母的矢量曲线数据
  • 用于生成3D挤出和斜角的几何信息

.ttf字体转换为该格式,可以使用在线Facetype.js工具,该工具会生成一个.typeface.json文件。

添加TextGeometry

有了字体之后我们就可以添加我们的3D字体了!

async function init(font) {
   
    const sizes = {
        width: window.innerWidth,
        height: window.innerHeight,
    };


    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 1000);
    const renderer = new THREE.WebGPURenderer({antialias: true});

    document.body.appendChild(renderer.domElement);
    renderer.setSize(window.innerWidth, window.innerHeight);
    camera.position.z = 8;
    scene.add(camera);

    const textGeometry = new TextGeometry("Every", {
        font: font,
        size: 1.0,
        depth: 0.2,
        bevelEnabled: true,
        bevelThickness: 0.1,
        bevelSize: 0.01,
        bevelOffset: 0,
        bevelSegments: 1
    });

    const mesh = new THREE.Mesh(
        textGeometry,
        new THREE.MeshStandardMaterial({color: "#000000", metalness: 0.8, roughness: 0.8})
    );

    scene.add(mesh);

    renderer.renderAsync(scene, camera);
}

01.png

此时我们能发现,TextGeometry 的默认锚点在左下角,所以此时我们的字体是在原点的右上方向

解决方法,需要把几何体居中:

const mesh = new THREE.Mesh(
        textGeometry,
        new THREE.MeshStandardMaterial({color: "#000000", metalness: 0.8, roughness: 0.8})
    );

    textGeometry.computeBoundingBox();
    textGeometry.center();

02.png

修改顶点

在进行下一步前,我们首先来了解一个关于顶点还有法向的概念。

  1. PlaneGeometry演示
    const geometry = new THREE.PlaneGeometry(1, 1);

    const mesh = new THREE.Mesh(
        geometry,
        new THREE.MeshStandardMaterial({color: "#000000", metalness: 0.8, roughness: 0.8, side: THREE.DoubleSide})
    );

    scene.add(mesh);

    const axesHelper = new THREE.AxesHelper(5); 
    scene.add(axesHelper);

    const normalsHelper = new VertexNormalsHelper(mesh, 0.5, 0xff0000);
    scene.add(normalsHelper);

03.png

再打印一下顶点数据看看

 console.log('count:', count)
 console.log('array:', geometry.attributes.position.array);
count:4;
array:{
    "0": -0.5,
    "1": 0.5,
    "2": 0,
    "3": 0.5,
    "4": 0.5,
    "5": 0,
    "6": -0.5,
    "7": -0.5,
    "8": 0,
    "9": 0.5,
    "10": -0.5,
    "11": 0
}

很明显能看到,一个PlaneGeometry有四个顶点,顶点坐标存储在geometry.attributes.position中,法向量 (Normal Vector)的定义:表示表面方向的单位向量,垂直于表面指向外部。如图中红色线段所示~

  1. TSL动态修改顶点

既然我们已经知道PlaneGeometry由四个顶点组成,那么如果我们动态修改顶点看看呢!

const initial_position = storage(geometry.attributes.position, "vec3", count);
    const normal_at = storage(geometry.attributes.normal, "vec3", count);

    const u_input_pos = uniform(new THREE.Vector3(0, 0, 0));
    const u_input_pos_press = uniform(0.0);

     const position_storage_at = storage(new THREE.StorageBufferAttribute(count, 3), "vec3", count);

    // 把原始顶点坐标保存下来
    const compute_init = Fn(() => {
        position_storage_at.element(instanceIndex).assign(initial_position.element(instanceIndex));
    })().compute(count);

    renderer.computeAsync(compute_init);

    const compute_update = Fn(() => {

        const base_position = initial_position.element(instanceIndex);

        const normal = normal_at.element(instanceIndex);

        const current_position = position_storage_at.element(instanceIndex);

        const distance = length(u_input_pos.sub(base_position));

        const pointer_influence = step(distance, 0.5).mul(1.0);

        const disorted_pos = base_position.add(normal.mul(pointer_influence));

        current_position.assign(disorted_pos);

    })().compute(count);

    mesh.material.positionNode = position_storage_at.toAttribute();

  • distance = length(u_input_pos - base_position)

    → 鼠标点到该顶点的距离。

  • pointer_influence = step(distance, 0.5)

    → 如果顶点在 0.5 的范围内 = 1.0,否则 = 0.0。

  • disorted_pos = base_position + normal * influence

    → 如果鼠标在附近,就把顶点往法线方向挤出去。

最终把结果写回 current_position。

02.gif

  1. 替换回TextGeomerty

03.gif

可以看到此时已经有字体形变的效果了,这就是通过改变顶点坐标来实现的!但是此时效果还是比较僵硬没有过渡,所以我们需要在compute_update方法中加上过渡效果!

  1. 弹簧-阻尼
 const velocity_storage_at = storage(new THREE.StorageBufferAttribute(count, 3), "vec3", count);

const compute_init = Fn(() => {
    ...
    // 初始化一下速度
    velocity_storage_at.element(instanceIndex).assign(vec3(0.0, 0.0, 0.0));
    })().compute(count);


 const compute_update = Fn(() => {

        const base_position = initial_position.element(instanceIndex);

        const current_position = position_storage_at.element(instanceIndex);

        // 获取当前速度
        const current_velocity = velocity_storage_at.element(instanceIndex);

        const normal = normal_at.element(instanceIndex);

        const distance = length(u_input_pos.sub(base_position));

        const pointer_influence = step(distance, 0.5).mul(1.5);

        // 加入mix-鼠标划过Geometry才开始变化
        const disorted_pos = base_position.add(normal.mul(pointer_influence));
        disorted_pos.assign((mix(base_position, disorted_pos, u_input_pos_press)));


        // 更新速度
        current_velocity.addAssign(disorted_pos.sub(current_position).mul(u_spring));
        // 应用摩擦:velocity *= friction
        current_velocity.assign(current_velocity.mul(u_friction));
        // 更新位置:position += velocity
        current_position.addAssign(current_velocity);
        

    })().compute(count);

此时再来看看我们的效果

04.gif

加入噪声函数

我们目前是按照法向方向推出顶点所以每次的形变都是一致的,我们需要更多的随机性那么就要用到噪声函数

const base_position = initial_position.element(instanceIndex);

        const current_position = position_storage_at.element(instanceIndex);

        const current_velocity = velocity_storage_at.element(instanceIndex);

        const normal = normal_at.element(instanceIndex);

        const noise = mx_noise_vec3(current_position.mul(0.5).add(vec3(0.0, time, 0.0)), 1.0).mul(u_noise_amp);

        const distance = length(u_input_pos.sub(base_position));

        const pointer_influence = step(distance, 0.5).mul(1.5);

        const disorted_pos = base_position.add(noise.mul(normal.mul(pointer_influence)));

        disorted_pos.assign(rotate(disorted_pos, vec3(normal.mul(distance)).mul(pointer_influence)));

        disorted_pos.assign((mix(base_position, disorted_pos, u_input_pos_press)));


        current_velocity.addAssign(disorted_pos.sub(current_position).mul(u_spring));
        current_position.addAssign(current_velocity);
        current_velocity.assign(current_velocity.mul(u_friction));
  1. mx_noise_vec3

mx_noise_vec3Three.js TSL 中基于 MaterialX 标准 的 3D 噪声函数

函数签名和参数 mx_noise_vec3(position, scale)

参数说明:

  • position: vec3 - 3D 坐标位置,决定噪声的采样点
  • scale: float - 缩放因子,控制噪声的频率/细节程度

返回值: vec3 - 返回三维向量的噪声值,每个分量范围通常在 [-1, 1]

所以我们通过

const disorted_pos = base_position.add(noise.mul(normal.mul(pointer_influence)));

让形变强度产生了更丰富的变化

  1. rotate

没有旋转时:

  • 顶点只是沿法线方向进出移动
  • 像"呼吸"或"脉冲"效果

有旋转时:

  • 顶点既有法线方向的移动,又有围绕某轴的旋转
  • 产生"螺旋"或"涡流"效果
  • 在鼠标附近形成扭曲变形

此时效果如下

05.gif

加入颜色

终于到最后一步啦!

const emissive_color = color(new THREE.Color("#0000ff"));

    const vel_at = velocity_storage_at.toAttribute();

    const hue_rotated = vel_at.mul(Math.PI * 10.0);

    const emission_factor = length(vel_at).mul(10.0);

    mesh.material.emissiveNode = hue(emissive_color, hue_rotated).mul(emission_factor).mul(5.0);

emissiveNode就类似我们之前的glsl中的片段着色器,在每个像素中都会执行,所以我们根据每个片段的速度再用色相旋转让速度越大的片段,色相变化越多!

效果展示

参考文档

# Interactive Text Destruction with Three.js, WebGPU, and TSL

Vue 3全面提速剖析

“Vue 3 有时占用的 CPU 时间不到 Vue 2 的十分之一。”

本文带你拆解 Vue 3 在渲染性能、包体体积和内存占用三大维度的提速秘诀。

一、渲染更快

1.DOM 树级优化——砍掉整棵树的递归

Vue 2 的 Diff 以“递归+双指针”遍历整棵虚拟 DOM,哪怕节点结构从未变化。

Vue 3 引入 Block Tree:

  • 编译期把模板标记为静态或动态两种节点;

  • 运行时只追踪动态节点的扁平数组,Diff 时按索引线性比对,复杂度从 O(n) 降到 O(d),d 为动态节点数量。

    若模板没有 v-ifv-for 等结构指令,则整棵树被视为静态,直接跳过遍历。

2.静态提升——把不变的部分搬到渲染函数外

编译器会检测出纯静态的节点、属性或对象,提升到渲染函数作用域外,变成常量。

渲染函数每次执行时不再重新创建这些对象,显著降低内存分配压力和垃圾回收频率。

3.元素级优化——PatchFlag 精准更新

每个动态节点携带一个 PatchFlag 位标记:

  • 1 表示仅文本变化;
  • 2 表示仅 class 变化;
  • 4 表示仅 style 变化;

运行时根据标记走专用快速路径,避免全量属性比对。

例如,只有一个动态 class 的 <div> 会触发 单次 className 赋值 而非遍历所有属性。

二、包体更小

从“全家桶”到“模块化”

Vue 3 所有 API 均以 ES Module 导出,配合 Rollup / Webpack 的 Tree-Shaking,未使用的功能在构建时被剪除。

举例:若项目未使用 <transition>,则相关代码不会进入最终 bundle。

体积对比

  • 仅运行时:vue.runtime.esm-bundler.js 13 KB gzip

  • 完整功能(含响应式、编译器、内置组件)23 KB gzip

    相比 Vue 2 的 20-30 KB 起步,官方数据给出 整体缩小 41%。

三、内存更省

  • Proxy 替代 defineProperty:

    不再需要递归劫持每个属性,而是在访问时动态创建依赖,减少初始化内存峰值。

  • WeakMap 跟踪依赖:

    组件卸载时,响应式系统通过 WeakMap 自动释放,降低内存泄漏风险。

    官方基准测试显示,内存占用下降 54%。

四、综合收益:官方基准数据

  • 初次渲染速度 ↑55%
  • 更新渲染速度 ↑133%
  • 包体大小 ↓41%
  • 内存占用 ↓54%

踩坑vue项目中使用 iframe 嵌套子系统无法登录,不报错问题!

前言

最近项目中使用了 iframe 标签进行嵌套子系统时,遇到了无法登录、而且不报错的问题。本文记录自己的踩坑过程,也希望帮助到其他同学。

需求

有两个项目,a项目 和 b项目。 当前开发的 a 项目中需要嵌套展示已上线的 b 项目内容; 且要求 b 项目的功能可以正常使用。

遇到问题

在 a 项目中直接使用 iframe 标签进行嵌套 b 项目来展示。且两个项目部署在不同的服务器下。

  • a 项目:88 服务器
  • b 项目:89 服务器

调试中a 项目可以正常跳转到 b 项目,也能在 iframe 中展示出来, 直接跳转到b 项目的 login 页面后正常调用了登录接口, 且登录接口 200 通了, 返回了用户信息。

但是到这里就没有任何执行了,不会在登录成功后跳转到 b项目的系统首页。

问题分析

页面也没有任何报错,接口也都是通的。 卧槽? 这什么问题?

经过反复测试确实没有任何报错,接口也通了,token等用户信息也正常拿到了,就是不跳转 b 系统首页。

但是我发现当 ifreameurl 加载 b项目地址成功显示出 login 登录页面后,点击登录时,页面还会正常走登录接口,此时页面会进行刷新一下,也可以获取到 token 等信息,但还是不跳转对应首页。

此时我第一想法就是可能登录成功后被拦截了,导致无法跳转到 home 页。 难道是服务器给拦截了吗?

于是我上网查资料,得到的结果大部分都说是:这种 iframe 嵌套场景因为浏览器的限制,一些 cookie 信息会被拦截导致重定向到了 login 无法正常登录跳转。

问题原因

  • 第三方 Cookie 限制(主要问题)

    • 现代浏览器(Chrome >=80, Safari, Firefox)默认阻止跨域 iframe 的第三方 Cookie
    • 登录依赖的 Session/Cookie 被浏览器阻止写入
  • 同源策略限制

    • iframe 加载的页面与父页面不同源时,无法共享认证状态
  • X-Frame-Options 响应头

    • 目标页面可能设置了 X-Frame-Options: SAMEORIGIN 阻止嵌入
  • 登录逻辑差异

    • iframe 环境可能导致 JS 执行上下文差异(如 window.top 获取失败等问题)

关键代码

先开始我想到这个项目没用到 cookie 相关内容啊。 为了确定,于是尝试在项目里全局查找一下 cookie看看,果然发现了一个文件中包含如下代码:

import Cookies from 'js-cookie'
const TokenKey = 'saber-access-token'
const RefreshTokenKey = 'saber-refresh-token'
export function getToken() {
    return Cookies.get(TokenKey)
}

export function setToken(token) {
    return Cookies.set(TokenKey, token)
}

export function getRefreshToken() {
  return Cookies.get(RefreshTokenKey)
}

export function setRefreshToken(token) {
  return Cookies.set(RefreshTokenKey, token)
}

export function removeToken() {
    return Cookies.remove(TokenKey)
}

export function removeRefreshToken() {
  return Cookies.remove(RefreshTokenKey)
}

发现该项目使用了 js-cookie 来进行存储 token 等用户信息。

问题解决

经过踩坑,查资料总结一下几种解决方案:

方案1: 确保同源或同站

如果条件允许的话,让 iframe 子内容 和 父页面系统 使用相同的域名(或者至少是相同的顶级域名+相同的协议):

  • 父页面系统: https://app.example.com
  • iframe 子系统: https://service.example.com

方案2: 服务器设置 SameSite

主动设置 SameSiteSet-Cookie:Key=Value; SameSite=None; Secure确保协议为安全协议https

  • Java (Servlet) 配置示例
Cookie cookie = new Cookie("sessionid", "12345");
cookie.setSecure(true);
cookie.setHttpOnly(true);
cookie.setAttribute("SameSite", "None");
response.addCookie(cookie);
  • Nginx 配置示例
location / {
    proxy_pass http://backend;
    proxy_cookie_path / "/; SameSite=None; Secure";
}

方案3: 禁用浏览器 SameSite 默认限制 (仅测试用)

这只适用于开发和测试环境,不要在生产环境中使用。

  • Chrome 启动参数
chrome --disable-features=SameSiteByDefaultCookies
  • 注意事项
  1. 必须使用 HTTPSSameSite=None 必须与 Secure 属性一起使用,网站必须使用 HTTPS
  2. 浏览器兼容性:检查需要支持的浏览器版本是否支持 SameSite 属性
  3. 测试验证:在 Chrome 开发者工具的 Application > Cookies 部分检查 Cookie 是否正确设置了 SameSite 属性

方案4(推荐): localStorage 代替 js-cookie

修改原项目中使用的 cookie 相关代码。直接弃用 js-cookie,使用 localStorage 来存储用户信息。

// import Cookies from 'js-cookie'
const TokenKey = 'saber-access-token'
const RefreshTokenKey = 'saber-refresh-token'
export function getToken() {
  // return Cookies.get(TokenKey)
  return localStorage.getItem(TokenKey)
}

export function setToken(token) {
  // return Cookies.set(TokenKey, token)
  return localStorage.setItem(TokenKey,token)
}

export function getRefreshToken() {
  // return Cookies.get(RefreshTokenKey)
  return localStorage.getItem(RefreshTokenKey)

}

export function setRefreshToken(token) {
  // return Cookies.set(RefreshTokenKey, token)
  return localStorage.setItem(RefreshTokenKey,token)

}

export function removeToken() {
    // return Cookies.remove(TokenKey)
    return localStorage.remove(TokenKey)
}

export function removeRefreshToken() {
  // return Cookies.remove(RefreshTokenKey)
  return localStorage.remove(RefreshTokenKey)
}

总结

我使用了方案4最简单的方式,直接弃用 js-cookie,改用 localStorage。重新将 b 项目打包部署后,在 a 项目的 iframe 标签内进行嵌套展示,发现登录成功后正常跳转到了 b 项目的首页,且功能正常。

从卡顿到飞驰:我是如何用WebAssembly引爆React性能的

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_d49oajb5hb448rp2pnvf.webp

> **“那天用户发来投诉邮件,说我们的图片编辑器比蜗牛还慢——这句话像刀子一样扎进我的心里。”**

作为React开发者,我曾以为性能优化已做到极致,直到亲眼目睹用户上传4K图片时卡顿12秒的绝望表情。那一刻,我决定背水一战,而WebAssembly成了我的秘密武器。


噩梦般的性能现场

我引以为傲的图片编辑器,在真实场景中崩溃了:

| 操作              | 用户反馈                  | 我的羞愧指数 |
|-------------------|--------------------------|--------------|
| 应用滤镜          | “点击后可以去冲杯咖啡”    | 😫😫😫😫😫    |
| 保存高清图        | “趁这时间我能回封邮件”    | 😫😫😫😫      |
| 批量处理          | 直接刷新页面放弃操作      | 😫😫😫😫😫    |

根本诊断:当JS遇到1600万像素的图片时,就像用美工刀砍大树——完全不对等!


初见WebAssembly的震撼

凌晨2点的技术探索中,我偶然点开MDN的WebAssembly文档。测试第一个Demo时,我看到了神迹:

// C++编写的矩阵运算
const matrixMultiply = (a, b) => {
  // ... 千行优化代码 ...
};

// 编译为Wasm后调用
const wasmMultiply = wasmInstance.exports.matrix_multiply;

// 性能对比
console.time('JS');
matrixMultiply(hugeMatrixA, hugeMatrixB); // 耗时 4.2 秒
console.timeEnd('JS');

console.time('Wasm');
wasmMultiply(hugeMatrixA, hugeMatrixB);   // 耗时 0.3 秒
console.timeEnd('Wasm');

我的显示器见证了咖啡喷溅的瞬间:13倍性能提升?!


React+Wasm黄金组合架构

经过三周不眠夜,我悟出完美分工方案:

graph LR
    A[React组件] -->|传递图像数据| B[WebAssembly引擎]
    B -->|返回处理结果| C[Canvas渲染]
    D[用户操作] --> A
    
    subgraph Wasm领域
    B --> E[CPU密集型运算]
    E --> F[SIMD优化]
    F --> G[多线程处理]
    end

具体分工

  • React:状态管理/UI响应/事件处理
  • Wasm:像素级运算/复杂算法/内存操作

我的血泪集成之路

1. Wasm模块加载(踩坑3天)

useEffect(() => {
  const initWasm = async () => {
    try {
      // 曾在这里栽跟头:路径错误导致404
      const wasm = await fetch('/wasm/image_processor_bg.wasm');
      const buffer = await wasm.arrayBuffer();
      
      // 内存泄漏重灾区!
      const module = await WebAssembly.instantiate(buffer, {
        env: { 
          memory: new WebAssembly.Memory({ initial: 256 })
        }
      });
      
      setWasmEngine(module);
    } catch (e) {
      // 真实教训:必须处理加载失败!
      showErrorToast(`Wasm加载失败: ${e.message}`);
    }
  };
  
  initWasm();
  
  // 关键!组件卸载时释放内存
  return () => wasmEngine?.instance.exports.cleanup();
}, []);

2. 图像处理核心(性能突破点)

const applyFilter = useCallback(async (imageData) => {
  if (!wasmEngine) return;
  
  try {
    // 获取Wasm内存空间(关键步骤!)
    const memory = wasmEngine.instance.exports.memory;
    const { width, height, data } = imageData;
    
    // 在Wasm内存中分配空间
    const inputPtr = wasmEngine.instance.exports.alloc(data.length);
    const inputBuffer = new Uint8Array(memory.buffer, inputPtr, data.length);
    inputBuffer.set(data);
    
    // 调用Wasm处理函数(速度奇迹发生地)
    const outputPtr = wasmEngine.instance.exports.apply_filter(
      inputPtr,
      width,
      height,
      filterConfig // 滤镜参数
    );
    
    // 取回处理结果
    const result = new Uint8ClampedArray(
      memory.buffer,
      outputPtr,
      width * height * 4
    );
    
    // 手动释放内存!(血的教训)
    wasmEngine.instance.exports.dealloc(inputPtr);
    wasmEngine.instance.exports.dealloc(outputPtr);
    
    return new ImageData(result, width, height);
  } catch (e) {
    // 错误处理不能少!
    captureException(e);
    return imageData;
  }
}, [wasmEngine]);

性能对比:从绝望到狂喜

操作 纯JS实现 Wasm方案 提升倍数
4K高斯模糊 12.4s 0.18s 69x
200张缩略图生成 43s 1.2s 36x
实时滤镜预览 卡顿掉帧 60fps流畅

用户邮件从投诉变成赞叹:

“你们换了服务器吗?现在快得像闪电!” —— 而真相是前端革命。


用鲜血换来的经验

1. 内存管理生死令

// Rust示例:必须显式释放
#[wasm_bindgen]
pub fn alloc(size: usize) -> *mut u8 {
    let buf = Vec::with_capacity(size);
    let ptr = buf.as_ptr();
    std::mem::forget(buf); // 防止Rust自动回收
    ptr
}

#[wasm_bindgen]
pub fn dealloc(ptr: *mut u8, size: usize) {
    unsafe {
        let _ = Vec::from_raw_parts(ptr, 0, size);
    }
}

我的事故报告:忘记释放导致2GB内存泄漏,用户浏览器崩溃

2. 数据传输成本陷阱

// 错误示范:频繁传递大对象
const result = wasm.processHugeArray(fullData); // 拖垮性能!

// 正确做法:内存共享
const ptr = wasm.alloc(data.length);
const buffer = new Uint8Array(wasm.memory.buffer, ptr, data.length);
buffer.set(fullData);
wasm.processInPlace(ptr); // 原地处理

3. 工具链的黑暗森林

我的推荐工具栈:

编译工具: wasm-pack (Rust) / Emscripten (C++)
调试工具: Chrome DevTools  Memory面板
监控方案: Performance API + 自定义指标
热重载: 配置wasm-pack --watch

何时该亮出Wasm利剑

必用场景

  • 实时视频处理
  • 3D物理引擎计算
  • 密码学运算
  • 复杂算法可视化

避坑指南

  • 简单表单验证
  • API数据获取
  • 基础状态管理
  • 小型动画效果

我的Wasm实战案例库

  1. 医疗影像查看器
    DICOM文件解析提速40倍

  2. 浏览器内视频剪辑
    4K时间线实时预览

  3. 金融数据沙盒
    蒙特卡洛模拟前端运行

  4. AR试妆镜
    实时人脸特效60fps


给勇士的启程指南

journey
    title Wasm学习路径(从入门到精通)
    section 第一阶段:基础入门
      安装Rust工具链: 配置rustup、cargo、wasm-pack(Wasm编译核心工具) --> 写第一个Hello World: 编译简单代码为.wasm,验证运行环境
    section 第二阶段:前端交互
      实现数组求和: 在Wasm侧编写算法,测试基础逻辑正确性 --> 与React通信: 用wasm-bindgen实现Wasm和React的双向数据传递
    section 第三阶段:高级移植
      移植C++算法: 用Emscripten将现有C++算法编译为Wasm --> 内存优化实战: 解决Wasm内存拷贝问题,释放无用内存
    section 终极挑战:性能突破
      多线程Wasm: 基于SharedArrayBuffer实现Wasm并发执行 --> GPU加速: 结合WebGPU让Wasm调用显卡算力

起步命令

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
cargo install wasm-pack
wasm-pack new my-wasm-module

最后的性能哲学

“当用户说‘太快了’时,我知道那些不眠夜都值了——WebAssembly不是魔法,但让前端做到了十年前不敢想的事。”

现在我的编辑器启动画面上写着:“Powered by Rust ♥ React”。这不仅是技术组合,更是性能B格的宣言。

❌