阅读视图

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

TypeScript高手密技:解密类型断言、非空断言与 `const` 断言

什么是类型断言?

TypeScript 允许你覆盖它的推断,并且能以你任何你想要的方式分析它,这种机制被称为「类型断言」。 TypeScript 类型断言用来告诉编译器你比它更了解这个类型,并且它不应该再发出错误。

如何使用类型断言?

TypeScript提供了两种语法:

  1. as 语法(推荐)

    let someValue: unknown = "this is a string";
    let strLength: number = (someValue as string).length;
    
  2. 尖括号语法

类似于那些静态语言的类型转换

```typescript
let someValue: unknown = "this is a string";
let strLength: number = (<string>someValue).length;
```

高级断言技巧

  1. 非空断言操作符(!

    当你非常确定一个值不是nullundefined时,可以在变量或表达式后使用!

    function process(user: User | null) {
      // 编译器会报错,因为 user 可能为 null
      // console.log(user.name);
    
      // 我确信在调用这个函数时,user 绝不为 null
      console.log(user!.name); 
    }
    

    警告: 这是在手动关闭TypeScript的一个重要安全检查。请仅在你100%确定该值非空的情况下使用,否则它会隐藏潜在的null引用错误,直到运行时才爆发。

  2. const 断言 (as const)

    这是一个非常强大的工具,用于告诉TypeScript将一个表达式推断为最具体、最不可变的类型。

    • 对于字面量:类型将是字面量本身,而不是通用的stringnumber
    • 对于对象:所有属性都会变成readonly
    • 对于数组:会变成readonly元组(tuple)。
    // 没有 as const
    let config = { url: '/api/data', method: 'GET' };
    // config 类型: { url: string; method: string; }
    
    // 使用 as const
    let constConfig = { url: '/api/data', method: 'GET' } as const;
    // constConfig 类型: { readonly url: "/api/data"; readonly method: "GET"; }
    
    // 对于数组
    let permissions = ['read', 'write'];
    // permissions 类型: string[]
    
    let constPermissions = ['read', 'write'] as const;
    // constPermissions 类型: readonly ["read", "write"]
    

何时避免使用断言?

  1. 优先使用类型守卫(Type Guards)typeofinstanceofin 操作符或自定义的is谓词函数是更安全的选择,因为它们会在运行时进行检查,并在此基础上智能地收窄类型。

    // 不推荐的方式
    function printLength(value: string | string[]) {
      if ((value as string).length) { // 危险的断言
        console.log((value as string).length);
      } else {
        console.log((value as string[]).length);
      }
    }
    
    // 推荐的方式:使用类型守卫
    function printLengthSafe(value: string | string[]) {
      if (typeof value === 'string') {
        console.log(value.length); // value 在这里是 string 类型
      } else {
        console.log(value.length); // value 在这里是 string[] 类型
      }
    }
    
  2. 避免 as anyas any 是最后的手段,它会完全关闭对该变量的类型检查。

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript/TypeScript开发干货

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

🎉 Eficy 让你的 Cherry Studio 直接生成可预览的 React 页面

Eficy 是一个零构建的 JSX 运行时。它可以在浏览器中直接渲染 JSX,使用已有的 React 组件,无需打包与编译;注册一次 React 组件即可作为协议元素使用(如 e-Button);内置 Signal,状态管理更简单,非常适合 LLM 生成页面的场景。

English | 简体中文

⚡ 快速页面生成(LLM + shadcn/ui 提示词)

如果你希望快速使用 Eficy 完成页面生成,可以参考根目录中的 llm_shadcn.txt 提示词集合:

  • 包含内容:Eficy + shadcn/ui 的最佳实践提示词、e- 前缀的组件协议、可直接使用的 HTML 模板与常见示例
  • 使用方式:
    1. 打开 llm_shadcn.txt
    2. 在支持 HTML 预览的 LLM 客户端(例如 Cherry Studio)中,按照提示词生成基于 Eficy + shadcn/ui 的页面
    3. 直接在聊天窗口中预览效果,无需复制到本地 HTML 文件
  • 相关链接:浏览器使用指南

llm_shadcn.txt

🎯 为什么选择 Eficy?

Eficy 让你可以:

  1. 无构建运行 JSX — 在纯 HTML 中使用 <script type="text/eficy">
  2. 协议化组件 — 统一注册 React 组件,使用 e-Button 等协议元素,保证 LLM 输出一致
  3. 简单的响应式状态 — 内置 Signal,细粒度更新
  4. 可选 UnoCSS 插件 — 从 className 自动生成原子化样式

✨ 核心特性

🔄 基于 Signal 的响应式系统

  • 直观的状态管理 - 摆脱复杂的 React Hooks
  • 自动依赖追踪 - JSX 中使用的 Signal 会自动被追踪
  • 细粒度更新 - 只有使用了变化 Signal 的组件会重新渲染
  • 异步数据支持 - 内置异步 Signal,自动处理加载和错误状态

🚀 无编译渲染

  • 直接浏览器执行 - 在浏览器环境中直接运行 JSX
  • Script 标签支持 - 使用 <script type="text/eficy"> 进行内联 JSX
  • 实时转译 - 即时将 JSX 转换为可执行的 JavaScript

🧩 协议化组件渲染

  • 前缀式组件 - 使用 e-Button 语法调用已注册组件
  • 全局组件注册 - 一次注册,处处使用
  • 一致的 LLM 输出 - 减少 LLM 生成组件的差异性

🎨 UnoCSS 集成

  • 原子化 CSS 生成 - 自动从 className 属性生成样式
  • 实时样式处理 - 在渲染过程中提取并生成 CSS
  • 智能缓存 - 避免重复生成相同样式

📦 无缝 React 集成

  • 完整 React 兼容 - 与现有 React 组件库协同工作
  • 自定义 JSX Runtime - 与 Signal 透明集成
  • TypeScript 支持 - 完整的类型安全

📦 安装

npm install eficy
# 或
yarn add eficy
# 或
pnpm add eficy

🚀 快速开始

浏览器使用(无需编译)

<!DOCTYPE html>
<html>
<head>
  <title>Eficy Demo</title>
  <script type="module" src="https://unpkg.com/@eficy/browser/dist/standalone.mjs"></script>
</head>
<body>
  <div id="root"></div>
  
  <script type="text/eficy">
    import { signal } from 'eficy';
    import * as antd from 'antd';
    
    // 注册组件
    Eficy.registerComponents(antd);
    
    const App = () => {
      const count = signal(0);
      const name = signal('World');
      
      return (
        <div className="p-6 bg-gray-100 min-h-screen">
          <h1 className="text-2xl font-bold mb-4">Hello, {name}!</h1>
          <p className="mb-4">Count: {count}</p>
          
          <input 
            className="border p-2 mr-2"
            value={name}
            onChange={(e) => name.set(e.target.value)}
            placeholder="Enter your name"
          />
          
          <e-Button 
            type="primary" 
            onClick={() => count.set(count() + 1)}
          >
            Increment
          </e-Button>
        </div>
      );
    };
    
    Eficy.render(App, document.getElementById('root'));
  </script>
</body>
</html>

Node.js 使用

import React from 'react';
import { createRoot } from 'react-dom/client';
import { create, EficyProvider } from 'eficy';
import { signal } from '@eficy/reactive';
import * as antd from 'antd';

// 创建 Eficy 实例
const core = await create();

// 注册组件
core.registerComponents(antd);

const App = () => {
  const count = signal(0);
  const name = signal('Eficy');
  
  return (
    <div className="p-6 bg-gray-100 min-h-screen">
      <h1 className="text-2xl font-bold mb-4">Hello, {name}!</h1>
      <p className="mb-4">Count: {count}</p>
      
      <input 
        className="border p-2 mr-2"
        value={name}
        onChange={(e) => name.set(e.target.value)}
        placeholder="Enter your name"
      />
      
      <e-Button 
        type="primary" 
        onClick={() => count.set(count() + 1)}
      >
        Increment
      </e-Button>
    </div>
  );
};

// 渲染应用
const root = createRoot(document.getElementById('root'));
root.render(
  <EficyProvider core={core}>
    <App />
  </EficyProvider>
);

🧠 核心概念

使用 Signal 进行状态管理

import { signal, computed } from 'eficy';

// 创建状态 Signal
const count = signal(0);
const name = signal('World');

// 创建计算属性
const greeting = computed(() => `Hello, ${name()}!`);

// 在 JSX 中使用(自动订阅)
const App = () => (
  <div>
    <h1>{greeting}</h1>
    <p>Count: {count}</p>
    <button onClick={() => count.set(count() + 1)}>
      Increment
    </button>
  </div>
);

异步数据处理

import { asyncSignal } from 'eficy';

const userList = asyncSignal(async () => {
  const response = await fetch('/api/users');
  return response.json();
});

const UserList = () => (
  <div>
    {userList.loading() && <div>Loading...</div>}
    {userList.error() && <div>Error: {userList.error().message}</div>}
    {userList.data() && (
      <ul>
        {userList.data().map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    )}
  </div>
);

协议化组件

// 全局注册组件
core.registerComponents({
  Button: ({ children, ...props }) => (
    <button className="px-4 py-2 bg-blue-500 text-white rounded" {...props}>
      {children}
    </button>
  )
});

// 使用 e- 前缀调用
const App = () => (
  <div>
    <e-Button onClick={() => console.log('Clicked!')}>
      Click me
    </e-Button>
  </div>
);

📦 包含的模块

Eficy 完整包包含以下核心模块:

核心框架

  • @eficy/core-jsx - 第三代核心引擎,基于自定义 JSX runtime
  • @eficy/reactive - 高性能响应式状态管理系统
  • @eficy/reactive-react - React 响应式集成
  • @eficy/reactive-async - 异步响应式支持

内置插件

  • @eficy/plugin-unocss - UnoCSS 原子化 CSS 自动生成插件

特殊包

  • @eficy/browser - 为浏览器环境准备的无需编译包

🖥 支持环境

  • 现代浏览器
  • Node.js 环境
  • 服务端渲染
  • Electron
IE / Edge
IE / Edge
Firefox
Firefox
Chrome
Chrome
Safari
Safari
Electron
Electron
IE11, Edge last 2 versions last 2 versions last 2 versions last 2 versions

📚 相关文档

🔗 API 参考

核心 API

  • create() - 创建预配置的 Eficy 实例
  • EficyProvider - 提供 Eficy 上下文的组件
  • useEficyContext() - 获取 Eficy 实例的 Hook

响应式 API

  • signal(value) - 创建响应式信号
  • computed(fn) - 创建计算属性
  • asyncSignal(fn, options) - 创建异步信号
  • useObserver(fn) - React Hook,监听信号变化

插件 API

  • core.install(Plugin, config) - 安装插件
  • core.registerComponent(name, component) - 注册单个组件
  • core.registerComponents(components) - 批量注册组件

Jenkins部署前端项目实战方案

概述

在目前工程化项目中,项目上线必定经过代码编写---》打包---》部署的流程,如果没有一个自动化部署的方案,按照传统项目开发方式,我们只有每次项目开发完后,手动打包项目,然后登录服务器,将我们本地打包后的项目传到服务器,这个是非常麻烦了,对于开发者来说,我们应该将精力放在开发上,对于前端来说,可能有时候部署还要麻烦后端帮忙,这个也是非常的尴尬,因此,打包部署这块最好交给自动化工具处理,下面我会以实际公司项目通过自动化工具打包部署的流程给大家呈现一遍前端项目部署到服务器的自动化流程。

工具

Jenkins

什么是Jenkins?

Jenkins 是一个开源的、用 Java 编写的持续集成和持续交付 (CI/CD)  工具。它主要用于自动化软件开发过程中的各种任务,对于企业项目开发自动化来说,是非常强大的工具,内置很多插件,可以供我们完成更多复杂的任务,例如:

  •  自动构建代码
  •  自动运行测试
  •  自动打包应用程序
  •  自动部署到服务器
  •  生成测试报告和构建状态

安装Jenkins

我们可以将Jenkins安装到自己本地电脑上,也可以安装到服务器上,我这里以安装到自己电脑为例给大家讲解,系统windows,当然,安装到服务器的好处是可以团队共享,如果自己一个人使用,安装到本地也是没问题的。

Jenkins大体工作流程

image.png

如果需要安装linux版本的,安装步骤参考官方提供的方案(cloud.tencent.com/developer/a…

1、选择安装版本

我以最新稳定版为例,安装包链接如下:

www.jenkins.io/download/

选择windows版本

image.png

2、安装

等待下载完成,按照提示安装即可,需要设置登录账号、密码、访问端口等。

注意:Jenkins的主目录最好不好放到C盘,后续产生的工作空间文件会越来越大,建议放其他盘符。

3、安装成功

访问:http://localhost:8089, 出现如下页面就成功了(我安装的时候配置的端口是8089),自己可以改动。注意选择安装推荐插件,等待插件安装完成即可。

image.png 插件安装完成后,登录后来到genkins首页如下:

image.png

安装必要插件

一些必须的插件,比如(git),这些基础的上面推荐插件已经都装过了,我们略过

插件安装位置如下: image.png 除开默认进来安装的推荐插件之外,我们还需要安装如下的几个插件:

NodeJS Plugin

前端项目构建,必须要用到的node image.png

Publish Over SSH

前端项目构架完成,需要将本地构建的产物,推送到对应服务器,因此需要借助这个插件完成。

image.png

系统配置

1、配置node相关

要打包前端项目,首先需要用到node,上面下载了node相关插件后,我们需要来到tool下配置,如图所示:

image.png

找到nodeJS安装,配置需要用到的node版本,注意,可以添加多个,在项目构建的时候我们可以选择在这里配置的node版本。 image.png

2、配置推送服务相关

项目构建打包后,需要配置服务器相关的信息,然后才能推送到服务器对于目录下:

选择System

image.png 输入服务器账号对应的密码,这里除开密码登录,也可以使用ssh,我这里采用账号密码的形式。

image.png 配置服务器信息

image.png

创建构建任务

上面配置好后,我们就可以进行构建任务创建了

1、新建item

点击新建item

image.png 输入任务名称,以及构建类型,推荐自建创建或者管道方式(常用),这里我选择用的freeStyle-project

image.png

2、任务配置

创建完成后,需要对构建任务进行配置,包含git、node、脚本等

源码管理

首选配置git相关

image.png 点击添加,追加git账户信息,其他默认,只需要输入git账号名和密码即可,然后上面就可以选择了

image.png

环境配置

选择前面流程配置过的node,勾选如下: image.png

构建脚本配置

选择构建步骤中的Execute Windows batch command,用来执行构建项目的脚本,因为基于windows系统的,所以必须选这个,如果是linux系统的话,Excute shell image.png 添加如下几个构建命令,具体根据自己项目实际情况而定

  • npm -v
  • node -v
  • npm i pnpm -g
  • pnpm i
  • pnpm run build image.png

推送服务器配置

选择构建步骤Send files or execute commands over SSH

image.png 配置需要上传的文件,以及上传到服务器的位置目录 image.png

构建

上面的配置完成后,就可以构建项目了,在构建任务列表,进入需要构建的任务下

image.png 选择构建即可 image.png 查看构建任务,下面列表的构建历史以及正在构建的任务,点击查看构建任务详情,可以看到有正在构建的日志等等。

image.png

构建出错相关日志可以在下面查看,构建出错可以按照日志进行修改配置这些。 image.png

服务器查看

进入服务器,可以看到我们构建的任务成功上传到了服务器。

image.png

总结

Jenkins对于自动化流程来说,功能非常强大,我们可以利用不同插件实现不同效果,仅仅使用上面的配置,也能完整闭环项目从开发到部署流程,要做其他更加复杂的操作,比如:上传到网盘、发送邮件这些,都可以借助插件完成,通过这个工具我们可以极大提升我们的开发体验。

深入WeakMap和WeakSet:管理数据和防止内存泄漏

image.png

咱们做前端的,天天都在跟ObjectArray打交道。但ES6其实还给我们提供了另外两个非常有意思的数据结构:WeakMapWeakSet

说实话,我刚开始学这两个东西的时候,也觉得有点鸡肋。MapSet用得好好的,为啥非要搞个“Weak(弱鸡)”版本?而且它们还不能遍历,功能上好像还是阉割版。

直到有一次,我排查一个线上页面卡顿的问题,用Chrome的内存快照(Heap Snapshot)工具,发现了一些本该被销毁的DOM节点,却因为被我写的一个全局Map引用着,导致一直无法被垃圾回收(GC),造成了内存泄漏。

从那次之后,我才真正理解了WeakMapWeakSet存在的意义。它们不是MapSet的替代品,而是为了解决一类非常特殊的、关于内存管理问题而生的。


JavaScript的垃圾回收

在聊WeakMap之前,我们必须花一分钟,快速回顾一下JS的垃圾回收机制。

现代JS引擎(比如V8)的GC,主要依赖一个叫做 可达性(Reachability) 的概念。简单说就是:

  • 有一个“根”对象(比如全局的window对象,或者函数内部的局部变量)。
  • 从这个“根”出发,能通过引用链找到的对象,就是可达的,意味着它们是“活的”,不能被回收。
  • 如果一个对象,从任何一个“根”出发,都无法找到它了,那它就是不可达的,GC就会在适当的时候把它清理掉,释放内存。

现在,问题来了。如果我们用一个普通的Map来存储一些数据,会发生什么?

// 假设这是一个全局的Map,用来缓存一些DOM节点的信息
const domCache = new Map();

function cacheNodeInfo() {
  const element = document.getElementById('my-element');
  
  if (element) {
    // 我们把DOM节点作为key,存储了一些信息
    domCache.set(element, { someInfo: '...' });
  }
}

cacheNodeInfo();

// 后来,我们在某个操作中,把这个DOM节点从页面上移除了
document.getElementById('my-element').remove();

内存泄漏就发生在这里!

虽然你已经把#my-element这个DOM节点从页面上移除了,但因为全局的domCache这个Map,还通过key的方式,强引用着这个DOM节点对象,所以从GC的角度看,这个节点依然是可达的。

只要domCache不被销毁,这个DOM节点对象和它关联的数据,就会永远待在内存里,无法被回收。


WeakMap如何解决这个问题

WeakMap就是为了解决上面这个问题而生的。

它的核心特性就一个:它的key必须是对象,并且这个key是对对象的弱引用。

“弱引用”是什么意思?

它是一种不会被GC计入可达性判断的引用。GC看到一个对象只被弱引用指着,就会认为:“哦,没人需要你了”,然后就会把它回收掉。

我们把上面的例子改成WeakMap

const domCache = new WeakMap(); // 改成WeakMap

function cacheNodeInfo() {
  const element = document.getElementById('my-element');
  
  if (element) {
    domCache.set(element, { someInfo: '...' });
  }
}

cacheNodeInfo();

document.getElementById('my-element').remove();

// 在下一次GC发生时,由于#my-element这个DOM节点对象不再被任何强引用指向
// (页面上没了,domCache的引用又是弱的),
// GC就会把它连同domCache里与之关联的{ someInfo: '...' }一起回收掉。
// 内存泄漏的问题,就这么自动解决了。

WeakMap的这个特性,决定了它最典型的应用场景:

将一些元数据(metadata),附加到一个宿主对象上,并且不影响这个宿主对象的生命周期。

就像上面的例子,DOM节点是宿主,我们想给它附加一些额外信息,但我们不希望因为我们的附加行为,导致这个DOM节点无法被正常销毁。


我在哪里用到了WeakMap

1. 缓存DOM节点相关的数据

这是最经典的用法,就像上面的例子一样。比如,你需要给某个DOM节点关联一个复杂的配置对象、一个事件监听器集合、或者一个类的实例。

2. Vue 3响应式系统的核心

我后来去看Vue 3的源码,发现WeakMap在它的响应式系统里扮演了至关重要的角色。

Vue 3用Proxy来实现响应式。它需要一个地方,来存储“原始对象”和它对应的“响应式代理对象”之间的映射关系,避免重复代理。

// 极简化的伪代码
const reactiveMap = new WeakMap();

function reactive(target) {
  // 如果已经代理过,直接返回缓存的代理
  if (reactiveMap.has(target)) {
    return reactiveMap.get(target);
  }

  const proxy = new Proxy(target, ...);
  // 缓存映射关系
  reactiveMap.set(target, proxy);
  return proxy;
}

这里为什么必须用WeakMap?因为如果用普通Map,只要reactiveMap存在,所有被代理过的“原始对象”就永远不会被销毁,即使你的组件早就卸载了,页面上已经没人用它了。这会造成巨大的内存泄漏。


WeakSet呢?它有什么用?

理解了WeakMapWeakSet就很好懂了。

  • Set存储的是值的强引用集合。
  • WeakSet存储的是对象弱引用集合。

WeakSet的用处相对少一些,它主要用来标记一个对象,判断一个对象是否“在”或者“不在”某个集合里,同样不影响这个对象的垃圾回收。

一个常见的场景:防止重复处理

假设你有一个函数,需要处理一些DOM节点,但你想确保同一个节点在一次操作中只被处理一次。

const processedNodes = new WeakSet();

function processNode(node) {
  if (processedNodes.has(node)) {
    // 已经处理过了,直接跳过
    return;
  }
  
  // ...这里是处理节点的逻辑...
  console.log('Processing node:', node);
  
  // 处理完,把它加到集合里,做个标记
  processedNodes.add(node);
}

const node1 = document.getElementById('node-1');
const node2 = document.getElementById('node-2');

processNode(node1); // 会执行
processNode(node2); // 会执行
processNode(node1); // 会跳过

这里用WeakSet的好处是,当node1从页面上被移除后,processedNodes里对它的弱引用会自动消失,不会造成内存泄漏。


什么时候该用它们?

你应该优先考虑使用WeakMapWeakSet的场景,都符合一个核心特征:

你想把一个“对象A”和一些“信息B”关联起来,但是“信息B”的生命周期,应该完全依赖于“对象A”的生命周期。当“对象A”被销- 时,“信息B”也应该被自动清理掉。

  • WeakMap:用在需要{ 对象A: 信息B }这种键值对关联的场景。
  • WeakSet:用在只需要标记[ 对象A ]是否存在的场景。

它们都不是万能的。因为它们不可遍历,功能也有限。但在合适的场景下,它们是解决内存泄漏问题。

希望这篇文章,能让你们对这两个低调的API,有更深的理解❀。

🔥深入浅出前后端Cookie跨域

经典案发现场:

你(前端,抓狂中):
“后端大佬!我 fetch 加了 credentials: 'include'axios 也开了 withCredentials,为啥 Cookie 还是没带上啊?😭”

后端(一脸无辜):
“我响应头 Access-Control-Allow-Credentials: true 也给了啊,Access-Control-Allow-Origin 也指定你家域名了,咋回事?”

… 半小时的互相“甩锅”后,终于发现:Set-CookieSameSite 设成了 Lax,被 Chrome 默默拦截了!

别慌!
今天咱们就化身技术侦探,把跨域 Cookie 从“发送”到“接收”这条路上的所有关卡、陷阱、隐藏规则,一次性扒个干净!让你从此告别“Cookie去哪儿了”的灵魂拷问。


一、背景:为啥跨域带个Cookie这么“矫情”?

想象 Cookie 是张 VIP 通行证。浏览器这个“保安大队长”有个铁律:同源策略—— 只有协议 (http/https)、域名 (a.com)、端口 (8080) 完全一致,才算“自己人”,才能自由带证进出。

一旦有不同(比如 http://a.com 访问 https://api.a.com 或者 http://b.com),就是“跨域”。为了安全(防 CSRF 攻击)和隐私(限制第三方追踪),浏览器又加了两道超级安检门:

  1. CORS (跨域资源共享):后端说了算!“你(前端)想带通行证(Cookie)?行,但得先问问我(后端)同不同意,同意的凭证(响应头)得给我看!”
  2. SameSite / Partitioned 属性:浏览器说了算!“就算后端同意了,你这通行证(Cookie)本身也得符合我的安全规定(属性设置)才能放行!”

任何一道安检没通过,你的 Cookie 就像人间蒸发,浏览器还常常不给明确报错! 这就是为啥它这么让人头大。


二、全流程图解

一张图理清 Cookie 的跨域之旅


[你的网站] https://www.your-app.com
       │
       │ 1. 发起请求 (GET/POST...)
       │    ✅ 关键:前端正确设置"携带凭证"标志
       │        - fetch: `{ credentials: 'include' }`
       │        - axios: `{ withCredentials: true }`
       │        - xhr: `xhr.withCredentials = true`
       ▼
[目标API] https://api.data-service.com/user
       ▲
       │ 2. 后端响应
       │    ✅ 关键:设置正确的 CORS 响应头 + Set-Cookie
       │        - `Access-Control-Allow-Origin: https://www.your-app.com` (精确匹配!)
       │        - `Access-Control-Allow-Credentials: true`
       │        - `Set-Cookie: session=abc123; Domain=.data-service.com; Path=/; Secure; SameSite=None; Partitioned; HttpOnly`
       │
       │ 3. 浏览器安全审核
       │    ✅ 关键:Cookie 属性合规 + CORS 头匹配
       │        - 检查 SameSite, Secure, Domain, Partitioned 等
       │        - 核对 CORS 头是否允许来源和凭证
       │
       │ 4. 存储 Cookie (通过审核后)
       │
       │ 5. 下次请求自动携带 (符合 Domain/Path 规则时)
       └─── 请求头中出现:`Cookie: session=abc123`

⚠️ 重点警告: 1, 2, 3, 4, 5 任何一个环节出错,你的 Cookie 就会在某个环节“神秘消失”!


三、后端配置

如何让浏览器“安心收下”Cookie?(核心响应头 + Set-Cookie)

后端的响应是 Cookie 能否成功落地的第一步!请务必配置好这两组“通关文牒”:

1. CORS 响应头 (缺一不可!)

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.your-app.com  # 绝对不能用 '*'!必须是请求来源的确切域名!
Access-Control-Allow-Credentials: true                 # 明确告诉浏览器:允许前端带凭证(Cookie)来!
Access-Control-Allow-Methods: GET, POST, PUT, DELETE   # 允许的 HTTP 方法
Access-Control-Allow-Headers: Content-Type, Authorization, X-Custom-Header # 允许前端携带的请求头
Access-Control-Expose-Headers: X-Custom-Header         # (可选)允许前端读取的额外响应头
Access-Control-Max-Age: 86400                          # (可选)预检请求缓存时间(秒)

💡 Nginx/反向代理用户请注意: 如果你用了代理,确保这些 CORS 头是最终由你的应用服务器生成,或者你在代理层(如 Nginx)正确添加了它们!代理默认可能会吞掉应用设置的头。

2. Set-Cookie 响应头 (Cookie 的“出生证明”)

Set-Cookie: sessionId=xyz789abc;
           Domain=.data-service.com;    # 必须是目标 API 的域或其父域(如 .data-service.com 包含 api.data-service.com)。写错或范围太大(如 .com)会被拒收!
           Path=/;                      # 指定 Cookie 生效的路径。通常设 / 表示全站可用。
           Secure;                      # **强制要求**:Cookie 只通过 HTTPS 传输!本地 HTTP 调试时没有它可能行,上线 HTTPS 必挂!
           SameSite=None;               # **关键钥匙**:明确声明此 Cookie 可用于跨站(跨域)请求!用 Lax/Strict 跨域请求基本带不上。
           Partitioned;                # **未来通行证**:应对 Chrome 等浏览器逐步淘汰第三方 Cookie 的趋势,使用 Partitioned 属性是新的最佳实践,确保 Cookie 在跨站上下文中可用(需要 Chrome 114+,未来更重要)。
           HttpOnly;                    # (推荐)防止 JavaScript 读取,增强安全性(防 XSS)。
           Expires=Wed, 21 Aug 2025 07:28:00 GMT; # 或 Max-Age=2592000; 设置有效期。

🚫 后端常见踩坑点:

  • Domain 写错: 写成 '.com' (范围太大无效)、'api.data-service.com' (但前端是 'app.data-service.com' 且没设父域) 等。精准匹配或合理父域是关键!
  • 漏掉 Secure 本地 http://localhost 测试可能没事,一部署到线上 https 环境,Cookie 就因为缺少 Secure 标记被浏览器拒绝存储或发送!HTTPS 环境必须加!
  • SameSite 设成 Lax/Strict 这是导致“我明明配了 CORS 为啥 Cookie 还不带?”的头号元凶!跨域请求(尤其是非导航的 POST/PUT/DELETE 或非顶级导航的 iframe 内请求)需要 'SameSite=None'!记住:跨域带 Cookie,'SameSite=None' + 'Secure' 是黄金搭档!
  • 忽略 Partitioned (未来大坑): 随着 Chrome 逐步限制第三方 Cookie,未设置 'Partitioned' 的 'SameSite=None; Secure' Cookie 在跨站 iframe 等场景下可能未来会失效尽早适配 'Partitioned' 是明智之举!

四、前端配置

如何让浏览器“心甘情愿带上”Cookie?(不同请求方式配置)

前端同学,光后端配好了还不够!你得在发起请求时,明确打上“我要带通行证(Cookie)”的信号!而且不同请求库/方式,写法各异,一个字母都不能错!

请求方式 正确姿势 (✅) 错误示范 (❌) / 关键点
fetch fetch('https://api.com/data', { credentials: 'include' }) 不写 credentials 或写成 'same-origin' (只带同源 Cookie)
axios axios.get('https://api.com/data', { withCredentials: true }) 拼错 withCredentials (如 withCredential 少个 s),或 true 写成小写 true (JS 没事,但易错)
XMLHttpRequest const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.withCredentials = true; xhr.send(); 顺序很重要! 必须在 xhr.open() 之后,xhr.send() 之前设置 withCredentials
<form> 提交 <form action="https://api.com/submit" method="post" crossorigin="use-credentials"> 漏掉 crossorigin="use-credentials" 属性 (现代浏览器需要)
<img>/<script> <img src="https://track.com/pixel" crossorigin="use-credentials"> 漏掉 crossorigin="use-credentials" 属性。注意:即使带了,能否成功也受 Cookie 本身 SameSite 等属性限制。

📌 核心口诀: fetchincludeaxioswithCredentialsxhr 认准 withCredentials 属性且注意设置时机! 表单和资源标签记得加 crossorigin="use-credentials"


特别注意: withCredentials是一个特殊的头,和baseURL同一层级设置,其配置后在浏览器请求头的面板中是不可以见的。不能直接在header头中配置,否则cookie不会携带、设置cookie也不会成功。

Clipboard_Screenshot_1755569654.png


五、调试宝典:Cookie丢了?按步骤“破案”!

当 Cookie 神秘失踪时,别慌!拿起浏览器开发者工具 (DevTools),化身福尔摩斯,按顺序排查:

🔍 第一步:检查 Cookie 是否被成功“存进银行”(Application 面板)

  1. 打开 DevTools -> Application 标签页。
  2. 左侧选择 Storage -> Cookies
  3. 在域名列表中找到你的 目标 API 的域名 (如 api.data-service.com)。
  4. 查看你期望的 Cookie (如 sessionId) 是否存在。
    *   **不存在?** 问题大概率出在**后端 `Set-Cookie` 或浏览器安全策略拦截**上!立刻进行:
        *   检查 **Network 面板** 中对应 API 响应的 **`Set-Cookie` 头** 是否存在且格式正确(尤其 `Domain`, `Secure`, `SameSite=None`)。
        *   检查 **Console 面板** 有没有浏览器发出的黄色⚠️警告!常见如:
            *   `This Set-Cookie was blocked because it had the "SameSite=Lax" attribute but came from a cross-site response...` -> `SameSite` 设错了!
            *   `This Set-Cookie was blocked because it had the "Domain" attribute with a value that is a public suffix...` -> `Domain` 设置范围过大无效。
            *   `This Set-Cookie was blocked because it was not sent over a secure connection and had the "Secure" attribute.` -> 本地 HTTP 测试但 Cookie 设了 `Secure`。
    *   **存在?** 太好了!进入第二步。

🔍 第二步:检查 Cookie 是否被“带出门”(Network 面板)

  1. Network 标签页中,找到你怀疑没带 Cookie 的那个 具体请求
  2. 选中该请求,查看右侧的 Headers 选项卡。
  3. 展开 Request Headers 部分。
  4. 仔细找,看有没有 Cookie: your_cookie_name=value... 这一行。
    *   **有!** 恭喜!Cookie 成功带上了!问题可能在后端处理逻辑或后续环节(但这已不属于跨域携带问题)。
    *   **没有!** 问题在于:浏览器有 Cookie,但**这次请求没把它带出来**!重点排查:
        *   **前端“携带凭证”标志**:确认你的 `fetch`/`axios`/`xhr` 配置**绝对正确无误**(回看第四部分表格)。
        *   **Cookie`Path``Domain` 匹配**:请求的 URL 路径是否在 Cookie`Path` 范围内?请求的域名是否匹配 Cookie`Domain` 或其子域?
        *   **`Secure` Cookie 发给了 HTTP?** 如果 Cookie`Secure` 标记,但当前页面是 `http://`,请求也是 `http://`,浏览器不会发送它。确保全站 HTTPS。
        *   **`SameSite` 严格限制?** 虽然你存了 `SameSite=None`Cookie,但某些非常严格的上下文(如某些 iframe 沙盒环境)可能仍有限制。检查 Console 警告。

https请求下secure无值或为false也会设置失败Clipboard_Screenshot_1755570100.png

设置成功的情况下,浏览器面板中会有两条记录,如下图(设置):

Clipboard_Screenshot_1755570313.png设置成功的情况下携带cookie,如下图(携带): Clipboard_Screenshot_1755570544.png

🔍 第三步:特别关注 - 预检请求 (OPTIONS)

对于非简单请求(如用了 Content-Type: application/json 或自定义头 X-Whatever),浏览器会先发一个 OPTIONS 预检请求探路。

  • Network 面板找到这个 OPTIONS 请求。
  • 检查它的 Response Headers
    • 必须包含 Access-Control-Allow-Origin (你的确切来源) 和 Access-Control-Allow-Credentials: true
    • 如果请求带了自定义头,Access-Control-Allow-Headers 必须包含这些头名。
    • 如果 OPTIONS 请求失败 (状态码非 2xx),后面的正式请求根本不会发出,自然带不了 Cookie。预检失败是跨域问题的另一大常见原因!

六、小结速查表:关键点一网打尽!

环节 关键动作 避坑要点
后端 CORS Access-Control-Allow-Origin: <精确来源> + Access-Control-Allow-Credentials: true 绝对不能用 '*'
后端 Set-Cookie Domain=<正确域>; Secure; SameSite=None; Partitioned (Path/HttpOnly 按需) Domain 别乱写;SameSite=None 必须SecurePartitioned 是未来必备!
前端携带凭证 fetch: credentials: 'include'
axios: withCredentials: true
xhr: xhr.withCredentials = true
拼写/值要绝对正确!
xhr 顺序要对 (open 后, send 前)!
调试 - 存储 Application > Cookies > 目标域下是否存在?
看 Console 警告!
没有?查 Set-Cookie 响应头和 Console 拦截警告!
调试 - 携带 Network > 具体请求 > Request Headers > 找 Cookie: ... 有存但没带?查前端凭证配置、Cookie 的 Path/Domain 匹配、是否 HTTPS 环境、SameSite 是否真 None!
调试 - 预检 Network > OPTIONS 请求 > 状态码和 Response Headers 预检失败 (非 2xx) 正式请求不发!检查 OPTIONS 返回的 CORS 头是否齐全正确!

七、总结:让 Cookie 跨域如丝般顺滑

把跨域 Cookie 想象成一次跨国快递

  1. 发件人 (后端):

    • 准备合规包裹 (Set-Cookie):地址 (Domain) 邮编 (Path) 要准,贴好“易碎品”(HttpOnly)、“空运标”(Secure)、“海关申报单”(SameSite=None + Partitioned),写好有效期 (Expires/Max-Age)。
    • 开具通关许可 (CORS Headers):明确指定收件国 (Allow-Origin),声明允许携带特殊物品 (Allow-Credentials: true),列出允许的运输方式和物品清单 (Allow-Methods/Headers)。
  2. 快递员 (浏览器):

    • 严格安检:核对包裹信息是否合规(Cookie 属性),检查通关许可是否有效且匹配(CORS 头)。任何一项不符,直接扣留包裹(丢弃 Cookie)或退回(Console 警告)!
    • 安全运输:按规则 (Secure) 运送包裹。
  3. 寄件人 (前端):

    • 正确下单 (credentials/include/withCredentials):下单时必须明确勾选“寄送特殊物品/凭证” 的选项!选错或漏选,快递员根本不会去取件(不发送 Cookie)!

只有发件人包裹包得好、许可开得对,快递员安检过得去,寄件人下单下得准,这份珍贵的 Cookie “跨国快递” 才能准确、安全地送达目的地!

另外,后端通常会通过第三方库直接配置跨域,无需像上面一样逐一配置。如:

nestjs中的配置(内置支持):

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.enableCors({
    origin: (origin, callback) => {
      // 允许的origin
      const allowedOriginRegexList = [
        /your-app\.com/,
        /localhost/,
        /\d+\.\d+\.\d+\.\d+/,
        // 其它域名...
      ];
      // 本地调试放行
      // if (!origin) return callback(null, true);
      const isOk = allowedOriginRegexList.some((reg) => reg.test(origin));
      callback(null, isOk);
    },
    credentials: true,
    allowedHeaders: ['Content-Type', 'Authorization'],
    methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
  });

fastify中的配置:

import cors from 'fastify-cors'
// 跨域白名单
app.register(cors,  {
  origin: [
    /your-app\.com/,
    /localhost/,
    /\d+\.\d+\.\d+\.\d+/,
    // 其它域名...
  ],
  credentials: true
})

其它后端框架同理配置接口。

掌握这份通关手册,仔细对照每个环节,你一定能告别 Cookie 跨域丢失的烦恼!Cookie 永不迷路!🚀


上面的示例域名是通过whistle代理配置的, 具体配置方式可以参考这两篇文章

🔥一条命令搞定全局代理!告别Whistle插件依赖的极简方案
为什么推荐使用Whistle而不是Fiddler、Charles!🤗

# 设置代理规则(知乎 代码到 百度)
# https://www.zhihu.com https://www.baidu.com

https://api.data-service.com http://localhost:3636
https://www.your-app.com http://localhost:3838

一个有趣的搜索神器:pinyin-match

今天介绍一个拼音神器 pinyin-match:让搜索支持拼音模糊匹配

平时微信用得比较多的小伙伴, 你是否发现微信里使用拼音是可以搜索到对应的中文昵称的。

大家好,我是芝士,欢迎点此扫码加我微信 Hunyi32 交流

中文搜索一直是个小麻烦 —— 用户习惯输入拼音,而我们往往只能用中文才能搜到结果。 今天给你介绍的 pinyin-match,就完全可以解决这个麻烦!

它是一个轻量级的 JavaScript 库,帮你轻松实现中文 + 拼音的模糊搜索,支持全拼、缩写、混合输入,还能定位匹配位置,方便做高亮显示。

它能做什么?

pinyin-match 支持以下几种模式:

✅ 全拼匹配:beijing → 北京

✅ 首字母匹配:bj → 北京

✅ 模糊匹配:beij → 北京

✅ 混合匹配:bei京 → 北京

✅ 高亮定位:返回匹配区间 [start, end],方便 UI 标记

举个例子:

import PinyinMatch from 'pinyin-match'

let text = '123曾经沧海难为水'

console.log(PinyinMatch.match(text, 'cjc')) 
// [3, 5]  => 命中“曾经”

console.log(PinyinMatch.match(text, 'engjingcanghai')) 
// false  => 输入少了个“c”

使用场景

搜索框优化

拼音、首字母都能搜到目标中文

电商搜索平台

想买“华为手机”,却懒得切中文输入?直接打 hw,结果照样一键命中。

通讯录 / 联系人搜索

不记得名字怎么写?随便敲个拼音,就能在通讯录里立刻找到对方。再也不用担心输不出生僻字。

就如上面举例的微信通讯录一样

全局搜索 / Ctrl+F

在长文档、知识库、甚至整个应用里,Ctrl + F 的搜索功能也能支持拼音。输入 zgn 就能秒定位到“中华人民共和国”,查资料效率直线上升。

还有很多涉及到采用拼音匹配中文的场景都可以使用pinyin-match快速实现,提升产品体验。

实现原理

pinyin-match 可不是“简单字符串替换”那么粗暴。

pinyin-match 的底层实现其实类似 word-break 算法(对字符串进行分词匹配),流程大概是:

  1. 输入分词:把用户输入的拼音拆分成可能的音节组合

    • jinan → ji nan | jin an | ji na n(...)
  2. 中文转拼音:把候选中文转换成拼音(考虑多音字,例如“曾”可以是 ceng 也可以是 zeng)

  3. 匹配校验:比较分词结果和中文拼音,支持不完整输入(最后一个音可以只打到一半)。

比如:

  • 输入 cengd

  • 文本 我曾大 → 拼音 [wo, ceng/zeng, da]

  • 匹配结果命中 ceng da

这样一来,哪怕用户输入的拼音不完整、夹杂缩写,也能很聪明地匹配成功。

优势亮点

✅ 轻量级:不到 10KB,无额外依赖

🔄 多模式匹配:全拼、首字母、模糊、混合输入都能搞定

✨ 高亮支持:返回匹配区间,方便 UI 渲染搜索结果

🌏 简体 / 繁体:一行代码即可切换

⚡ 通用性强:Node.js / 浏览器 / 前端框架都能无缝使用

快速尝鲜

安装

npm install pinyin-match --save

项目引入

// 引入简体版:
import PinyinMatch from 'pinyin-match';  // es  

const PinyinMatch = require('pinyin-match'); // commonjs

如果你的项目涉及繁体字, 还需要另外引入繁体版本:

import PinyinMatch from 'pinyin-match/es/traditional.js'; // es  

const PinyinMatch = require('pinyin-match/lib/traditional.js'); // commonjs

简单使用

import PinyinMatch from 'pinyin-match'

let text = '白日依山尽,黄河入海流'

// 直接中文匹配
console.log(PinyinMatch.match(text, '黄河')) 
// [6, 7]

// 拼音全拼匹配
console.log(PinyinMatch.match(text, 'bairiyishanjin')) 
// [0, 4]

// 拼音缩写匹配
console.log(PinyinMatch.match(text, 'hhrhl')) 
// [6, 9]

// 模糊输入(最后一个字母没打完)
console.log(PinyinMatch.match(text, 'huan')) 
// [6, 6]

// 拼音 + 汉字混合
console.log(PinyinMatch.match(text, 'bai日')) 
// [0, 1]

// 无法命中
console.log(PinyinMatch.match(text, 'abcdef')) 
// false

如果你正在做搜索框、通讯录、知识库,强烈建议试试 pinyin-match

只要几行代码,你的搜索功能就能秒升级 —— 从“只能搜中文”,变成“中拼混合全能搜”。

大家好,我是芝士,欢迎点此扫码加我微信 Hunyi32 交流,最近创建了一个低代码/前端工程化交流群,欢迎加我微信 Hunyi32 进群一起交流学习,也可关注我的公众号[ 前端界 ] 持续更新优质技术文章

如果你对这个库感兴趣, 可以研究一下,🔗 项目地址:github.com/xmflswood/p…

基于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.社区搜索离线回溯系统设计:架构、挑战与性能优化|得物技术



文 / 煜宸

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

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

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

在浏览器里“养”一只会写字的仓鼠——AI SSE 流式文本生成全攻略

开场白:为什么你的 AI 像便秘?

想象一下你在咖啡馆里,隔壁桌的程序员小哥对着屏幕怒吼:“快出来啊!”
你以为他在催稿,结果他只是调 OpenAI 的接口——等待 8 秒一次性返回 800 个 token,像极了 90 年代的拨号上网。

而隔壁的隔壁桌,小姐姐的屏幕像打字机一样哒哒哒蹦字,用户眉开眼笑。
秘诀?SSE(Server-Sent Events)——把 AI 当成一只打字仓鼠,让它在笼子里边跑边喷墨,字就一行行蹦出来。


一、SSE 是什么?能吃吗?

特征 描述
全称 Server-Sent Events
协议 基于 HTTP/1.1 的单向流(服务器 → 浏览器)
内容类型 text/event-stream
重连 浏览器原生支持自动重连,比 WebSocket 省心
兼容性 除了 IE 和某些老安卓,全员 OK(2025 年了,IE 终于死了)

底层原理一句话:HTTP 长连接 + 纯文本帧格式
每个帧长这样(别眨眼):

data: 你好,我是仓鼠\n\n

两个换行代表一条消息结束,浏览器立刻触发 MessageEvent
如果你把 data: 后面塞进 JSON,就能带结构化数据,比如:

data: {"token":"机","done":false}\n\n

二、在 Node.js 端喂饱仓鼠

2.1 最小可运行示例(Express)

// server.js
import express from 'express';
import { OpenAI } from 'openai';

const app = express();
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

app.get('/stream', async (req, res) => {
  // 1. 告诉浏览器这是一只活仓鼠
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders(); // 立刻把 header 冲出去,不然浏览器会等

  const prompt = req.query.q || '写一首关于猫的诗';

  try {
    const stream = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [{ role: 'user', content: prompt }],
      stream: true,
    });

    for await (const chunk of stream) {
      const delta = chunk.choices[0]?.delta?.content || '';
      if (delta) {
        res.write(`data: ${JSON.stringify({ token: delta, done: false })}\n\n`);
      }
    }

    // 仓鼠跑完了,给个终止符
    res.write(`data: ${JSON.stringify({ token: '', done: true })}\n\n`);
    res.end();
  } catch (e) {
    res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`);
    res.end();
  }
});

app.listen(3000, () => console.log('仓鼠农场已开业:http://localhost:3000'));

2.2 防踩坑 Tips

  1. 代理层压缩
    Nginx 默认 gzip 会缓冲 SSE,关掉:
    proxy_buffering off;
    
  2. 浏览器缓存
    给 URL 加时间戳或 Cache-Control: no-store
  3. token 计费
    别忘了在 done:true 时把总用量回传前端,免得用户以为你偷字数。

三、在浏览器端看仓鼠表演

3.1 最小可运行示例(原生 JS)

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>仓鼠打字机</title>
  <style>
    #output { white-space: pre-wrap; font-family: 'Courier New', monospace; }
  </style>
</head>
<body>
  <input id="prompt" placeholder="说点什么…" />
  <button onclick="startStream()">喂仓鼠</button>
  <div id="output"></div>

  <script>
    const output = document.getElementById('output');

    function startStream() {
      output.textContent = '';
      const q = document.getElementById('prompt').value;
      const es = new EventSource(`/stream?q=${encodeURIComponent(q)}`);

      es.onmessage = e => {
        const { token, done, error } = JSON.parse(e.data);
        if (error) {
          output.textContent += '\n[系统提示] 仓鼠中暑:' + error;
          es.close();
          return;
        }
        output.textContent += token;
        if (done) es.close();
      };

      es.onerror = () => {
        output.textContent += '\n[系统提示] 仓鼠掉线,正在重连…';
      };
    }
  </script>
</body>
</html>

3.2 React 版(hooks 党福利)

// useSSE.js
import { useEffect, useState, useRef } from 'react';

export default function useSSE(url) {
  const [text, setText] = useState('');
  const esRef = useRef(null);

  useEffect(() => {
    if (!url) return;
    esRef.current = new EventSource(url);
    esRef.current.onmessage = e => {
      const { token, done, error } = JSON.parse(e.data);
      if (error) {
        setText(prev => prev + `\n[错误] ${error}`);
        esRef.current.close();
        return;
      }
      setText(prev => prev + token);
      if (done) esRef.current.close();
    };
    return () => esRef.current?.close();
  }, [url]);

  return text;
}

四、高级花样:给仓鼠加特效

特效 实现思路
打字机光标 用 CSS 动画 `::after { content: ' '; animation: blink 1s infinite; }`
Markdown 实时渲染 收到 token 后喂给 marked.parseInline,但记得节流 16ms
语音同步 把 token 同时送进 Web Speech API,speechSynthesis.speak(new SpeechSynthesisUtterance(delta))
中途打断 前端调用 es.close(),后端用 AbortController 取消 OpenAI 请求

五、一张图看懂数据流向

┌─────────────┐    HTTP GET /stream?q=猫    ┌─────────────┐
│ 浏览器页面  │◀─────────────────────────────│  Node 服务  │
│             │  text/event-stream          │             │
│  #output    │◁───data:{"token":"喵"}──────│  OpenAI API │
└─────────────┘                             └─────────────┘

(ASCII 艺术也是艺术,对吧?)


六、彩蛋:仓鼠的健康检查清单

  • 服务器 HTTP/2 会让 SSE 复用连接,延迟更低
  • 移动端杀掉 App 后重连,iOS Safari 会帮你自动续命
  • 如果 token 里本身带换行,记得 delta.replace(/\n/g, '\\n') 再 JSON 化
  • 不要把 res.write 放在 try 外面,否则异常时浏览器会看到半截 JSON

结语:把 AI 当宠物,而不是黑箱

AI 不再是神秘的水晶球,而是一只可观察、可打断、可撸毛的仓鼠。
给它一条跑道(SSE),你就能看到字节像毛毛雨一样落下。

下次面试官问你:“如何实现实时文本生成?”
你可以淡定地回答——

“我开了个农场,养了一只会打字的仓鼠。”

祝编码愉快,愿你的仓鼠永不掉线!

面试官:一个接口使用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 项目的首页,且功能正常。

我的网站被攻击了,被干掉了 120G 流量,还在持续攻击中...

你好,我是悟空。

背景

前几天,我正在用 Typora 写笔记时,发现图片无法自动上传到七牛云上了,然后登录到七牛云管理后台,发现已经欠费 20 多块钱了,CDN服务因欠费停了,导致我的网站 passjava.cn 上的所有图片都无法访问。

很奇怪,我用的都是免费的 10G 流量,很少会出现欠费的情况,怀疑是网站被攻击了。于是检查了最近网站的访问情况,吓我一跳,7天时间被访问了 123G 流量,14 万个独立 IP,可以肯定的是被肉鸡服务器攻击了。

图片图片

今天又看了下流量,发现还在攻击。

数据如下:

07-30 22:00~08-06 10:00 持续攻击了 61 万次,120G CDN 流量,难受 -_-

08-07 17:00~08-11 17:00 持续攻击了 41 万次,全部报403 错误,也没有造成 CDN 流量。因为在 08-08 18:00 开启了防盗链,所以攻击失败,舒服^_^

图片

我的网站也恢复了,可以查看:www.passjava.cn

处理过程

初步排查

查看了访问日志,就是一大堆肉鸡服务器不断地访问网站的 5 张图片,造成了大量 CDN 流量,因为按量付费,所以超了很多钱。如下图所示,访问日志中可以看到很多不同的 IP,持续访问网站的图片。

图片

处理方案

迅速建了个工单,找七牛云的工程师看看怎么整。

工程师回复如下:

图片

方案:在 控制台 - cdn - 统计分析 - 日志分析 中看 top访问情况,比如高频访问的URL和客户端IP。

根据top访问数据将非预期的来源IP、ua等拉黑处理下。

然后看了下高频访问的 ip,都是随机的,没什么规律,应该就是大量肉鸡服务器攻击的。所以用黑名单的方式是没办法限制这些肉鸡服务器的 IP 的,只能另寻方案。

工程师回复可以采取防盗链的方式。

防盗链

防盗链方案:

  • 1:referer防盗链: 只有携带了相应 referer 请求头的 http请求才能访问资源,但是对于技术来说,referer都是可以伪造的,存在一定的风险。
  • 2:时间戳防盗链,url带着e和token参数访问,e为过期时间,但是只要捕获到了url就可以访问资源了,只适用于访问xx次的场景。
  • 3:回源鉴权,这个你们可以尝试下,每次访问cdn图片时,会携带你们自己定义的访问参数去你们自己的服务器上鉴权,只有你们服务器鉴权通过,返回 httpcode=200 ,才会将图片资源给用户访问,否则无法返回图片。
  • 4:IP黑白名单,这个适合某一个网段内的ip访问资源,不适合官网使用,只有在 ip 白名单中的用户才可以访问你们的图片。
  • 5:UA黑白名单,通过 User-Agent 字段的值来允许或者阻止特定用户访加速域名。

目前只有方案 1 比较合适。

方案 1:referer防盗链: 只有携带了相应 referer 请求头的 http请求才能访问资源,但是对于技术来说,referer都是可以伪造的,存在一定的风险。

于是按照操作开启了 referer 防盗链。如下图所示,开启了白名单,并且不允许空 Referer。

图片

图片

选择“否”,这样的话,只有携带了相应 referer 请求头的 http请求才能访问资源。

简单来说,就是我把网站中的一张图片的链接,直接 copy 到浏览器的地址栏中然后回车,是访问不到的,会报 403 Forbidden 的错误。

我检查了下,攻击我的网站的方式确实是没有携带 referer 请求头的 http 请求。如下图所示,Top 1 的请求中的 Referer 为 “-”,表示没有携带 referer。

图片

选择不允许空 Referer 后,就可以拦截这种不带 Referer 的请求了。

带来的麻烦

设置了不允许空 Referer 后,我发现在 Typora 工具中的添加的网站图片无法加载了,因为 Typora 在访问图片时没有携带 Referer 去访问网站图片。

然后通过 AI 找到了一种解决方案,原理如下图所示。

图片图片

Nginx 配置

将本地 8888 端口的请求添加 Referer 后,再访问 CDN 域名。

图片

Proxifier

拦截 typora 中访问 cdn 图片的请求,并将请求发送给本地的 8888 端口。

图片

进程劫持

图片

总结

(1)7 天内我的网站 passjava.cn 被肉鸡刷掉 120 G CDN 流量,七牛云直接欠费 20 多元。

(2)开启 referer 防盗链后,攻击流量瞬间 403,但 Typora 里的图片也挂了。

(3)用 Nginx+Proxifier 给本地请求加 referer,既挡住攻击又让笔记图片正常显示。

从卡顿到飞驰:我是如何用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格的宣言。

React输入框优化:如何精准获取用户输入完成后的最终值?

大家好,我是小杨,今天要和大家分享一个我在实际项目中经常遇到的问题:如何在React中获取用户输入完成后的最终值,而不是每次按键都触发处理函数。

问题场景

在React中,我们通常会这样处理输入框:

function SearchBox() {
  const [inputValue, setInputValue] = useState('');

  const handleChange = (e) => {
    setInputValue(e.target.value);
    // 这里如果直接调用搜索API,会导致频繁请求
    // searchAPI(e.target.value);
  };

  return (
    <input 
      type="text"
      value={inputValue}
      onChange={handleChange}
    />
  );
}

这样做的缺点是:每次按键都会触发状态更新,如果我们在onChange中直接调用API或执行复杂计算,会导致性能问题。

解决方案

1. 使用防抖(Debounce)技术

import { useState, useEffect, useRef } from 'react';
import _ from 'lodash'; // 或者自己实现防抖

function SearchBox() {
  const [inputValue, setInputValue] = useState('');
  const [finalValue, setFinalValue] = useState('');
  
  // 使用useRef保持防抖函数的稳定性
  const debouncedSave = useRef(
    _.debounce((value) => {
      setFinalValue(value);
      // 这里可以安全地调用API
      // searchAPI(value);
    }, 500)
  ).current;

  useEffect(() => {
    return () => {
      // 组件卸载时取消防抖
      debouncedSave.cancel();
    };
  }, [debouncedSave]);

  const handleChange = (e) => {
    setInputValue(e.target.value);
    debouncedSave(e.target.value);
  };

  return (
    <div>
      <input 
        type="text"
        value={inputValue}
        onChange={handleChange}
      />
      <p>最终值: {finalValue}</p>
    </div>
  );
}

2. 使用onBlur事件(失去焦点时获取)

function SearchBox() {
  const [inputValue, setInputValue] = useState('');
  const [finalValue, setFinalValue] = useState('');

  const handleChange = (e) => {
    setInputValue(e.target.value);
  };

  const handleBlur = () => {
    setFinalValue(inputValue);
    // 这里可以安全地调用API
    // searchAPI(inputValue);
  };

  return (
    <div>
      <input 
        type="text"
        value={inputValue}
        onChange={handleChange}
        onBlur={handleBlur}
      />
      <p>最终值: {finalValue}</p>
    </div>
  );
}

3. 自定义hook实现防抖

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

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return debouncedValue;
}

function SearchBox() {
  const [inputValue, setInputValue] = useState('');
  const finalValue = useDebounce(inputValue, 500);

  useEffect(() => {
    if (finalValue) {
      // 这里可以安全地调用API
      // searchAPI(finalValue);
      console.log('最终值:', finalValue);
    }
  }, [finalValue]);

  const handleChange = (e) => {
    setInputValue(e.target.value);
  };

  return (
    <div>
      <input 
        type="text"
        value={inputValue}
        onChange={handleChange}
      />
      <p>最终值: {finalValue}</p>
    </div>
  );
}

方案对比

方案 优点 缺点 适用场景
防抖 实时性较好,用户停止输入后自动触发 需要额外库或自定义实现 搜索框、自动完成
onBlur 实现简单,不需要额外依赖 必须失去焦点才触发 表单输入验证
自定义hook 可复用,逻辑清晰 实现稍复杂 需要多处使用的场景

我的实践经验

在最近的项目中,我结合了防抖和onBlur两种方式:

function SmartInput({ onFinalChange }) {
  const [value, setValue] = useState('');
  const debouncedValue = useDebounce(value, 500);

  // 防抖触发
  useEffect(() => {
    if (debouncedValue) {
      onFinalChange(debouncedValue);
    }
  }, [debouncedValue, onFinalChange]);

  // 失去焦点时强制触发(即使防抖未完成)
  const handleBlur = () => {
    onFinalChange(value);
  };

  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
      onBlur={handleBlur}
    />
  );
}

这样既保证了用户连续输入时的性能,又确保在用户快速操作离开输入框时能立即获取最终值。

总结

在React中获取用户输入完成的最终值有多种方式,选择哪种取决于你的具体需求:

  1. 需要实时性但不想太频繁 → 防抖
  2. 需要确保用户完成输入 → onBlur
  3. 需要复用逻辑 → 自定义hook

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

❌