普通视图

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

基于 Squoosh WASM 的浏览器端图片转换库

作者 jump_jump
2026年1月8日 02:21

在 Web 开发中,图片处理是一个常见需求。传统方案要么依赖服务端处理,要么使用 Canvas API,但前者增加服务器负担,后者在压缩质量上不尽人意。Google 的 Squoosh 项目提供了基于 WASM 的高质量图片编解码器,但直接使用比较繁琐。

于是我封装了 use-squoosh,一个零依赖的浏览器端图片转换库,通过 CDN 按需加载编解码器,开箱即用。

为什么需要这个库

现有方案的局限性

方案 优点 缺点
服务端处理 稳定可靠 增加服务器负担、网络开销
Canvas API 无依赖 JPEG 质量差、不支持 WebP 编码
直接使用 @jsquash 质量好 需要手动管理多个包、配置 WASM
在线工具 简单 隐私风险、批量处理不便

Canvas 的质量问题

Canvas 的 toBlob()toDataURL() 方法虽然简单,但存在明显缺陷:

// Canvas 方式
canvas.toBlob(callback, 'image/jpeg', 0.8);

问题:

  1. JPEG 编码器质量较差,同等文件大小下清晰度不如专业编码器
  2. 不支持 WebP 编码(部分旧浏览器)
  3. 无法精确控制编码参数

Squoosh 的优势

Squoosh 是 Google Chrome Labs 开发的图片压缩工具,其核心是一系列编译为 WASM 的高性能编解码器:

  • MozJPEG:Mozilla 优化的 JPEG 编码器,同等质量下文件更小
  • libwebp:Google 官方 WebP 编解码器
  • OxiPNG:Rust 编写的 PNG 优化器

@jsquash 将这些编解码器封装为独立的 npm 包,但直接使用需要:

  1. 安装多个包(@jsquash/webp、@jsquash/png、@jsquash/jpeg)
  2. 手动处理 WASM 文件加载
  3. 管理编解码器的初始化

use-squoosh 解决了这些问题。

核心设计思路

零依赖 + CDN 加载

最核心的设计决策是:不打包编解码器,运行时从 CDN 加载

// 编解码器通过动态 import 从 CDN 加载
const url = `${cdnConfig.baseUrl}/@jsquash/webp@${version}/encode.js`;
const module = await import(/* @vite-ignore */ url);

好处:

  1. 库本身体积极小(< 5KB gzipped)
  2. 编解码器按需加载,不使用的格式不会下载
  3. 利用 CDN 缓存,多项目共享同一份 WASM

加载时机:

  • 首次调用转换函数时加载对应格式的编解码器
  • 加载后缓存到 window 对象,页面内复用
  • 支持预加载关键格式

Promise 缓存避免竞态

并发场景下可能同时触发多次加载:

// 错误示例:可能重复加载
async function getEncoder() {
  if (!cache.encoder) {
    cache.encoder = await import(url);  // 并发时会多次触发
  }
  return cache.encoder;
}

解决方案是缓存 Promise 而非结果:

// 正确示例:缓存 Promise
async function getCodec(type: CodecType): Promise<any> {
  const cache = getCache();
  if (!cache[type]) {
    // 缓存 Promise 本身,而非 await 后的结果
    cache[type] = import(/* @vite-ignore */ url);
  }
  const module = await cache[type];
  return module.default;
}

这样即使并发调用,也只会触发一次网络请求。

全局缓存支持多项目共享

编解码器挂载到 window 对象:

function getCache(): CodecCache {
  if (typeof window !== "undefined") {
    const key = cdnConfig.cacheKey;
    if (!(window as any)[key]) {
      (window as any)[key] = createEmptyCache();
    }
    return (window as any)[key];
  }
  return moduleCache;  // 非浏览器环境回退
}

好处:

  • 同一页面多个组件/库使用 use-squoosh,共享编解码器
  • 页面导航不重新加载(SPA 场景)
  • 可配置 cacheKey 实现隔离

实现细节

格式自动检测

当输入是 BlobFile 时,自动从 MIME 类型检测格式:

const FORMAT_MAP: Record<string, ImageFormat> = {
  "image/png": "png",
  "image/jpeg": "jpeg",
  "image/webp": "webp",
  // 同时支持扩展名
  png: "png",
  jpeg: "jpeg",
  jpg: "jpeg",
  webp: "webp",
};

export async function convert(
  input: ArrayBuffer | Blob | File,
  options: ConvertOptions = {},
): Promise<ArrayBuffer> {
  let buffer: ArrayBuffer;
  let fromFormat = options.from;

  if (input instanceof Blob || input instanceof File) {
    buffer = await input.arrayBuffer();
    // 自动检测格式
    if (!fromFormat && input.type) {
      fromFormat = getFormat(input.type) ?? undefined;
    }
  } else {
    buffer = input;
  }

  // ...
}

解码 -> 编码流程

图片转换本质是:解码为 ImageData → 编码为目标格式。

export async function decode(
  buffer: ArrayBuffer,
  type: ImageFormat,
): Promise<ImageData> {
  switch (type.toLowerCase()) {
    case "png": {
      const decoder = await getPngDecoder();
      return decoder(buffer);
    }
    case "jpeg":
    case "jpg": {
      const decoder = await getJpegDecoder();
      return decoder(buffer);
    }
    case "webp": {
      const decoder = await getWebpDecoder();
      return decoder(buffer);
    }
    default:
      throw new Error(`Unsupported decode type: ${type}`);
  }
}

export async function encode(
  imageData: ImageData,
  type: ImageFormat,
  options: { quality?: number } = {},
): Promise<ArrayBuffer> {
  switch (type.toLowerCase()) {
    case "png": {
      const encoder = await getPngEncoder();
      return encoder(imageData);  // PNG 无损,不需要 quality
    }
    case "jpeg":
    case "jpg": {
      const encoder = await getJpegEncoder();
      return encoder(imageData, { quality: options.quality ?? 75 });
    }
    case "webp": {
      const encoder = await getWebpEncoder();
      return encoder(imageData, { quality: options.quality ?? 75 });
    }
    default:
      throw new Error(`Unsupported encode type: ${type}`);
  }
}

CDN 配置系统

支持自定义 CDN 地址和版本:

export interface CDNConfig {
  baseUrl?: string;      // CDN 基础路径
  webpVersion?: string;  // @jsquash/webp 版本
  pngVersion?: string;   // @jsquash/png 版本
  jpegVersion?: string;  // @jsquash/jpeg 版本
  cacheKey?: string;     // window 缓存 key
}

const defaultCDNConfig: Required<CDNConfig> = {
  baseUrl: "https://cdn.jsdelivr.net/npm",
  webpVersion: "1.5.0",
  pngVersion: "3.1.1",
  jpegVersion: "1.6.0",
  cacheKey: "__ImageConverterCache__",
};

智能缓存清除: 只有 CDN 相关配置变更时才清除缓存:

export function configure(config: CDNConfig): void {
  const cdnKeys: (keyof CDNConfig)[] = [
    "baseUrl", "webpVersion", "pngVersion", "jpegVersion",
  ];

  // 只有这些字段变更才清除缓存
  const needsClearCache = cdnKeys.some(
    (key) => key in config && config[key] !== cdnConfig[key],
  );

  cdnConfig = { ...cdnConfig, ...config };

  if (needsClearCache) {
    clearCache();
  }
}

编解码器 URL 生成

统一管理编解码器的包名、版本和文件路径:

const codecConfig: Record<
  CodecType,
  { pkg: string; version: keyof CDNConfig; file: string }
> = {
  webpEncoder: { pkg: "@jsquash/webp", version: "webpVersion", file: "encode.js" },
  webpDecoder: { pkg: "@jsquash/webp", version: "webpVersion", file: "decode.js" },
  pngEncoder: { pkg: "@jsquash/png", version: "pngVersion", file: "encode.js" },
  pngDecoder: { pkg: "@jsquash/png", version: "pngVersion", file: "decode.js" },
  jpegEncoder: { pkg: "@jsquash/jpeg", version: "jpegVersion", file: "encode.js" },
  jpegDecoder: { pkg: "@jsquash/jpeg", version: "jpegVersion", file: "decode.js" },
};

async function getCodec(type: CodecType): Promise<any> {
  const cache = getCache();
  if (!cache[type]) {
    const { pkg, version, file } = codecConfig[type];
    const url = `${cdnConfig.baseUrl}/${pkg}@${cdnConfig[version]}/${file}`;
    cache[type] = import(/* @vite-ignore */ url);
  }
  const module = await cache[type];
  return module.default;
}

使用方式

基本使用

import { convert, pngToWebp, compress } from 'use-squoosh';

// 文件选择器获取图片
const file = input.files[0];

// PNG 转 WebP
const webpBuffer = await pngToWebp(file, { quality: 80 });

// 通用转换
const result = await convert(file, {
  from: 'png',    // Blob/File 可省略,自动检测
  to: 'webp',
  quality: 85
});

// 压缩(保持原格式)
const compressed = await compress(file, {
  format: 'jpeg',
  quality: 70
});

配置 CDN

import { configure } from 'use-squoosh';

// 使用 unpkg
configure({ baseUrl: 'https://unpkg.com' });

// 使用自托管 CDN
configure({ baseUrl: 'https://your-cdn.com/npm' });

// 锁定特定版本
configure({
  webpVersion: '1.5.0',
  pngVersion: '3.1.1',
  jpegVersion: '1.6.0'
});

预加载优化首屏

import { preload, isLoaded } from 'use-squoosh';

// 页面加载时预加载常用格式
await preload(['webp', 'png']);

// 检查加载状态
if (isLoaded('webp')) {
  // WebP 编解码器已就绪
}

工具函数

import { toBlob, toDataURL, download } from 'use-squoosh';

const buffer = await pngToWebp(file);

// 转为 Blob
const blob = toBlob(buffer, 'image/webp');

// 转为 Data URL(用于 img.src)
const dataUrl = await toDataURL(buffer, 'image/webp');

// 触发下载
download(buffer, 'converted.webp', 'image/webp');

自托管 CDN

如果不想依赖公共 CDN,可以自托管编解码器文件。

目录结构要求

your-cdn.com/npm/
  @jsquash/
    webp@1.5.0/
      encode.js
      decode.js
    png@3.1.1/
      encode.js
      decode.js
    jpeg@1.6.0/
      encode.js
      decode.js

获取文件

从 npm 下载对应版本:

# 下载 @jsquash 包
npm pack @jsquash/webp@1.5.0
npm pack @jsquash/png@3.1.1
npm pack @jsquash/jpeg@1.6.0

# 解压并部署到 CDN

配置使用

configure({
  baseUrl: 'https://your-cdn.com/npm',
  webpVersion: '1.5.0',
  pngVersion: '3.1.1',
  jpegVersion: '1.6.0'
});

压缩效果对比

以一张 1920x1080 的 PNG 截图为例:

输出格式 Quality 文件大小 压缩率
原始 PNG - 2.1 MB -
WebP 80 186 KB 91%
WebP 90 312 KB 85%
JPEG 80 245 KB 88%
JPEG 90 398 KB 81%

WebP 在同等视觉质量下,文件大小比 JPEG 小约 25-35%。

浏览器兼容性

需要支持 WebAssembly 和动态 import:

浏览器 最低版本
Chrome 57+
Firefox 52+
Safari 11+
Edge 16+

覆盖全球 95%+ 的用户。

与其他方案对比

特性 use-squoosh browser-image-compression 直接使用 @jsquash
包大小 < 5KB ~50KB ~2KB × 6
运行时依赖 CDN 加载 打包在内 需手动配置
WebP 支持
PNG 优化
质量控制
自动格式检测
预加载 需手动
自定义 CDN
TypeScript

总结

use-squoosh 通过以下设计实现了易用的浏览器端图片转换:

  1. 零依赖设计:编解码器按需从 CDN 加载,库本身极轻量
  2. Promise 缓存:避免并发场景重复加载
  3. 全局共享:多组件/项目复用编解码器
  4. 灵活配置:支持自定义 CDN 和版本锁定
  5. TypeScript:完整类型定义,开发体验好

项目已开源:github.com/wsafight/us…

欢迎提出 issue 和 PR。

参考资料

  • Squoosh - Google 的在线图片压缩工具
  • jSquash - Squoosh 编解码器的 npm 封装
  • WebAssembly - 浏览器端高性能运行时

每日一题-两个子序列的最大点积🔴

2026年1月8日 00:00

给你两个数组 nums1 和 nums2 。

请你返回 nums1nums2 中两个长度相同的 非空 子序列的最大点积。

数组的非空子序列是通过删除原数组中某些元素(可能一个也不删除)后剩余数字组成的序列,但不能改变数字间相对顺序。比方说,[2,3,5] 是 [1,2,3,4,5] 的一个子序列而 [1,5,3] 不是。

 

示例 1:

输入:nums1 = [2,1,-2,5], nums2 = [3,0,-6]
输出:18
解释:从 nums1 中得到子序列 [2,-2] ,从 nums2 中得到子序列 [3,-6] 。
它们的点积为 (2*3 + (-2)*(-6)) = 18 。

示例 2:

输入:nums1 = [3,-2], nums2 = [2,-6,7]
输出:21
解释:从 nums1 中得到子序列 [3] ,从 nums2 中得到子序列 [7] 。
它们的点积为 (3*7) = 21 。

示例 3:

输入:nums1 = [-1,-1], nums2 = [1,1]
输出:-1
解释:从 nums1 中得到子序列 [-1] ,从 nums2 中得到子序列 [1] 。
它们的点积为 -1 。

 

提示:

  • 1 <= nums1.length, nums2.length <= 500
  • -1000 <= nums1[i], nums2[i] <= 1000

 

点积:

定义 a = [a1a2,…, an] b = [b1b2,…, bn] 的点积为:

\mathbf{a}\cdot \mathbf{b} = \sum_{i=1}^n a_ib_i = a_1b_1 + a_2b_2 + \cdots + a_nb_n 

这里的 Σ 指示总和符号。

教你一步步思考 DP:从记忆化搜索到递推到空间优化(Python/Java/C++/Go)

作者 endlesscheng
2025年12月31日 12:12

一、寻找子问题

为方便描述,下文把 $\textit{nums}_1$ 和 $\textit{nums}_2$ 简称为 $a$ 和 $b$。

在示例 1 中,我们要解决的问题(原问题)是:

  • 从 $a=[2,1,-2,5]$ 和 $b=[3,0,-6]$ 中选两个长度相等的非空子序列 $c$ 和 $d$,计算 $c$ 和 $d$ 的点积的最大值。

注意:选出的子序列必须是非空的。

考虑从右往左选数字,用「选或不选」分类讨论:

  • 选 $a[3]$ 和 $b[2]$,需要解决的子问题为:从 $a=[2,1,-2]$ 和 $b=[3,0]$ 中选两个长度相等的子序列,计算两个子序列点积的最大值。由于我们选了元素,所以子序列可以为空。但这样思考的话,子问题就和原问题不相似了。为了保证子问题和原问题相似,我们可以再细分为两种情况:
    • 选 $a[3]$ 和 $b[2]$,且前面不再选数字。这意味着点积就是 $a[3]\cdot b[2]$。
    • 选 $a[3]$ 和 $b[2]$,且前面还要选数字。需要解决的子问题为:从 $a=[2,1,-2]$ 和 $b=[3,0]$ 中选两个长度相等的非空子序列,计算两个子序列点积的最大值。
  • 不选 $a[3]$,需要解决的子问题为:从 $a=[2,1,-2]$ 和 $b=[3,0,-6]$ 中选两个长度相等的非空子序列,计算两个子序列点积的最大值。
  • 不选 $b[2]$,需要解决的子问题为:从 $a=[2,1,-2,5]$ 和 $b=[3,0]$ 中选两个长度相等的非空子序列,计算两个子序列点积的最大值。

由于选或不选都会把原问题变成一个和原问题相似的、规模更小的子问题,所以可以用递归解决。

注 1:从右往左思考,主要是为了方便把递归翻译成递推。从左往右思考也是可以的。

注 2:动态规划有「选或不选」和「枚举选哪个」两种基本思考方式。子序列相邻无关一般是「选或不选」,子序列相邻相关(例如 LIS 问题)一般是「枚举选哪个」。本题用到的是「选或不选」。

二、状态定义与状态转移方程

根据上面的讨论,定义状态为 $\textit{dfs}(i,j)$,表示从 $a$ 的前缀 $[0,i]$ 和 $b$ 的前缀 $[0,j]$ 中选两个长度相等的非空子序列,计算两个子序列点积的最大值。

接下来,思考如何从一个状态转移到另一个状态。

用「选或不选」分类讨论:

  • 选 $a[i]$ 和 $b[j]$,且前面不再选数字。这意味着点积就是 $a[i]\cdot b[j]$。
  • 选 $a[i]$ 和 $b[j]$,且前面还要选数字。需要解决的子问题为:从 $a$ 的前缀 $[0,i-1]$ 和 $b$ 的前缀 $[0,j-1]$ 中选两个长度相等的非空子序列,计算两个子序列点积的最大值,即 $\textit{dfs}(i-1,j-1)$。再加上 $a[i]\cdot b[j]$,就得到了 $\textit{dfs}(i,j)$。
  • 前两种情况可以合并为:$\max(\textit{dfs}(i-1,j-1), 0) + a[i]\cdot b[j]$。
  • 不选 $a[i]$,需要解决的子问题为:从 $a$ 的前缀 $[0,i-1]$ 和 $b$ 的前缀 $[0,j]$ 中选两个长度相等的非空子序列,计算两个子序列点积的最大值,即 $\textit{dfs}(i-1,j)$。
  • 不选 $b[j]$,需要解决的子问题为:从 $a$ 的前缀 $[0,i]$ 和 $b$ 的前缀 $[0,j-1]$ 中选两个长度相等的非空子序列,计算两个子序列点积的最大值,即 $\textit{dfs}(i,j-1)$。

这四种情况取最大值,就得到了 $\textit{dfs}(i,j)$,即

$$
\textit{dfs}(i,j) = \max{\max(\textit{dfs}(i-1,j-1), 0) + a[i]\cdot b[j], \textit{dfs}(i-1,j),\textit{dfs}(i,j-1)}
$$

递归边界:$\textit{dfs}(-1,j)=\textit{dfs}(i,-1)=-\infty$。此时其中一个数组没有元素,无法选出非空子序列,不合法。用 $-\infty$ 表示不合法的状态,从而保证 $\max$ 不会取到不合法的状态。

递归入口:$\textit{dfs}(n-1,m-1)$,这是原问题,也是答案。其中 $n$ 是 $a$ 的长度,$m$ 是 $b$ 的长度。

三、递归搜索 + 保存递归返回值 = 记忆化搜索

考虑到整个递归过程中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化:

  • 如果一个状态(递归入参)是第一次遇到,那么可以在返回前,把状态及其结果记到一个 $\textit{memo}$ 数组中。
  • 如果一个状态不是第一次遇到($\textit{memo}$ 中保存的结果不等于 $\textit{memo}$ 的初始值),那么可以直接返回 $\textit{memo}$ 中保存的结果。

注意:$\textit{memo}$ 数组的初始值一定不能等于要记忆化的值!例如初始值设置为 $0$,并且要记忆化的 $\textit{dfs}(i,j)$ 也等于 $0$,那就没法判断 $0$ 到底表示第一次遇到这个状态,还是表示之前遇到过了,从而导致记忆化失效。一般把初始值设置为 $-1$,但本题子序列点积可能是负数,可以初始化为 $\infty$。

Python 用户可以无视上面这段,直接用 @cache 装饰器。

具体请看视频讲解 动态规划入门:从记忆化搜索到递推【基础算法精讲 17】,其中包含把记忆化搜索 1:1 翻译成递推的技巧。

###py

class Solution:
    def maxDotProduct(self, nums1: List[int], nums2: List[int]) -> int:
        # 返回从 nums1[:i+1] 和 nums2[:j+1] 中选两个长度相同的【非空】子序列的最大点积
        @cache  # 缓存装饰器,避免重复计算 dfs(一行代码实现记忆化)
        def dfs(i: int, j: int) -> int:
            if i < 0 or j < 0:
                # 其中一个数组没有元素,无法选出非空子序列
                return -inf  # 下面计算 max 不会取到无解情况

            # 选 nums1[i] 和 nums2[j]
            # 和前面的子序列拼起来,或者不拼(作为子序列的第一个数)
            res1 = max(dfs(i - 1, j - 1), 0) + nums1[i] * nums2[j]

            # 不选 nums1[i]
            res2 = dfs(i - 1, j)

            # 不选 nums2[j]
            res3 = dfs(i, j - 1)

            return max(res1, res2, res3)

        return dfs(len(nums1) - 1, len(nums2) - 1)

###java

class Solution {
    public int maxDotProduct(int[] nums1, int[] nums2) {
        int n = nums1.length;
        int m = nums2.length;

        int[][] memo = new int[n][m];
        for (int[] row : memo) {
            Arrays.fill(row, Integer.MAX_VALUE);
        }

        return dfs(n - 1, m - 1, nums1, nums2, memo);
    }

    // 从 nums1[0,i] 和 nums2[0,j] 中选两个长度相同的【非空】子序列的最大点积
    private int dfs(int i, int j, int[] nums1, int[] nums2, int[][] memo) {
        if (i < 0 || j < 0) {
            // 其中一个数组没有元素,无法选出非空子序列
            return Integer.MIN_VALUE; // 下面计算 max 不会取到无解情况
        }

        if (memo[i][j] != Integer.MAX_VALUE) { // 之前计算过
            return memo[i][j];
        }

        // 选 nums1[i] 和 nums2[j]
        // 和前面的子序列拼起来,或者不拼(作为子序列的第一个数)
        int res1 = Math.max(dfs(i - 1, j - 1, nums1, nums2, memo), 0) + nums1[i] * nums2[j];

        // 不选 nums1[i]
        int res2 = dfs(i - 1, j, nums1, nums2, memo);

        // 不选 nums2[j]
        int res3 = dfs(i, j - 1, nums1, nums2, memo);

        memo[i][j] = Math.max(res1, Math.max(res2, res3)); // 记忆化
        return memo[i][j];
    }
}

###cpp

class Solution {
public:
    int maxDotProduct(vector<int>& nums1, vector<int>& nums2) {
        int n = nums1.size();
        int m = nums2.size();
        vector memo(n, vector<int>(m, INT_MAX));

        // 从 nums1[0,i] 和 nums2[0,j] 中选两个长度相同的【非空】子序列的最大点积
        auto dfs = [&](this auto&& dfs, int i, int j) -> int {
            if (i < 0 || j < 0) {
                // 其中一个数组没有元素,无法选出非空子序列
                return INT_MIN; // 下面计算 max 不会取到无解情况
            }

            int& res = memo[i][j]; // 注意这里是引用
            if (res != INT_MAX) { // 之前计算过
                return res;
            }

            // 选 nums1[i] 和 nums2[j]
            // 和前面的子序列拼起来,或者不拼(作为子序列的第一个数)
            res = max(dfs(i - 1, j - 1), 0) + nums1[i] * nums2[j];

            // 不选 nums1[i]
            res = max(res, dfs(i - 1, j));

            // 不选 nums2[j]
            res = max(res, dfs(i, j - 1));

            return res;
        };

        return dfs(n - 1, m - 1);
    }
};

###go

func maxDotProduct(nums1, nums2 []int) int {
n := len(nums1)
m := len(nums2)
memo := make([][]int, n)
for i := range memo {
memo[i] = make([]int, m)
for j := range memo[i] {
memo[i][j] = math.MaxInt
}
}

// 从 nums1[:i+1] 和 nums2[:j+1] 中选两个长度相同的【非空】子序列的最大点积
var dfs func(int, int) int
dfs = func(i, j int) int {
if i < 0 || j < 0 {
// 其中一个数组没有元素,无法选出非空子序列
return math.MinInt // 下面计算 max 不会取到无解情况
}

p := &memo[i][j]
if *p != math.MaxInt { // 之前计算过
return *p
}

// 选 nums1[i] 和 nums2[j]
// 和前面的子序列拼起来,或者不拼(作为子序列的第一个数)
res1 := max(dfs(i-1, j-1), 0) + nums1[i]*nums2[j]

// 不选 nums1[i]
res2 := dfs(i-1, j)

// 不选 nums2[j]
res3 := dfs(i, j-1)

*p = max(res1, res2, res3) // 记忆化
return *p
}

return dfs(n-1, m-1)
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(nm)$,其中 $n$ 是 $\textit{nums}_1$ 的长度,$m$ 是 $\textit{nums}_2$ 的长度。由于每个状态只会计算一次,动态规划的时间复杂度 $=$ 状态个数 $\times$ 单个状态的计算时间。本题状态个数等于 $\mathcal{O}(nm)$,单个状态的计算时间为 $\mathcal{O}(1)$,所以总的时间复杂度为 $\mathcal{O}(nm)$。
  • 空间复杂度:$\mathcal{O}(nm)$。保存多少状态,就需要多少空间。

四、1:1 翻译成递推

我们可以去掉递归中的「递」,只保留「归」的部分,即自底向上计算。

具体来说,$f[i+1][j+1]$ 的定义和 $\textit{dfs}(i,j)$ 的定义是一样的,都表示从 $a$ 的前缀 $[0,i]$ 和 $b$ 的前缀 $[0,j]$ 中选两个长度相等的非空子序列,计算两个子序列点积的最大值。这里 $+1$ 是为了把 $\textit{dfs}(-1,j)$ 和 $\textit{dfs}(i,-1)$ 也翻译过来,这样我们可以把 $f[0][j]$ 和 $f[i][0]$ 作为初始值。

相应的递推式(状态转移方程)也和 $\textit{dfs}$ 一样:

$$
f[i+1][j+1] = \max{\max(f[i][j], 0) + a[i]\cdot b[j], f[i][j+1],f[i+1][j]}
$$

初始值:$f$ 第一行和第一列初始化成 $-\infty$,翻译自递归边界 $\textit{dfs}(-1,j)=\textit{dfs}(i,-1)=-\infty$。

答案为 $f[n][m]$,翻译自递归入口 $\textit{dfs}(n-1,m-1)$。

###py

class Solution:
    def maxDotProduct(self, nums1: List[int], nums2: List[int]) -> int:
        n, m = len(nums1), len(nums2)
        f = [[-inf] * (m + 1) for _ in range(n + 1)]
        for i, x in enumerate(nums1):
            for j, y in enumerate(nums2):
                f[i + 1][j + 1] = max(max(f[i][j], 0) + x * y, f[i][j + 1], f[i + 1][j])
        return f[n][m]

###java

class Solution {
    public int maxDotProduct(int[] nums1, int[] nums2) {
        int n = nums1.length;
        int m = nums2.length;

        int[][] f = new int[n + 1][m + 1];
        for (int[] row : f) {
            Arrays.fill(row, Integer.MIN_VALUE);
        }

        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                f[i + 1][j + 1] = Math.max(
                    Math.max(f[i][j], 0) + nums1[i] * nums2[j],
                    Math.max(f[i][j + 1], f[i + 1][j])
                );
            }
        }
        return f[n][m];
    }
}

###cpp

class Solution {
public:
    int maxDotProduct(vector<int>& nums1, vector<int>& nums2) {
        int n = nums1.size();
        int m = nums2.size();
        vector f(n + 1, vector<int>(m + 1, INT_MIN));
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                int res1 = max(f[i][j], 0) + nums1[i] * nums2[j];
                // 注:max({...}) 比 max(..., max(...)) 慢
                f[i + 1][j + 1] = max(res1, max(f[i][j + 1], f[i + 1][j]));
            }
        }
        return f[n][m];
    }
};

###go

func maxDotProduct(nums1, nums2 []int) int {
n := len(nums1)
m := len(nums2)
f := make([][]int, n+1)
for i := range f {
f[i] = make([]int, m+1)
for j := range f[i] {
f[i][j] = math.MinInt
}
}

for i, x := range nums1 {
for j, y := range nums2 {
f[i+1][j+1] = max(max(f[i][j], 0)+x*y, f[i][j+1], f[i+1][j])
}
}
return f[n][m]
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(nm)$,其中 $n$ 是 $\textit{nums}_1$ 的长度,$m$ 是 $\textit{nums}_2$ 的长度。
  • 空间复杂度:$\mathcal{O}(nm)$。

五、空间优化

类似 1143. 最长公共子序列 的空间优化方法,只用一个长为 $m+1$ 的一维数组,原理讲解请看 最长公共子序列 编辑距离【基础算法精讲 19】

###py

# 更快的写法见【Python3 手写 max】
class Solution:
    def maxDotProduct(self, nums1: List[int], nums2: List[int]) -> int:
        m = len(nums2)
        f = [-inf] * (m + 1)
        for x in nums1:
            pre = f[0]
            for j, y in enumerate(nums2):
                tmp = f[j + 1]
                f[j + 1] = max(max(pre, 0) + x * y, f[j + 1], f[j])
                pre = tmp
        return f[m]

###py

class Solution:
    def maxDotProduct(self, nums1: List[int], nums2: List[int]) -> int:
        m = len(nums2)
        f = [-inf] * (m + 1)
        for x in nums1:
            pre = f[0]
            for j, y in enumerate(nums2):
                tmp = f[j + 1]
                res = x * y
                if pre > 0: res += pre
                if f[j] > res: res = f[j]
                if f[j + 1] > res: res = f[j + 1]
                f[j + 1] = res
                pre = tmp
        return f[m]

###java

class Solution {
    public int maxDotProduct(int[] nums1, int[] nums2) {
        int m = nums2.length;
        int[] f = new int[m + 1];
        Arrays.fill(f, Integer.MIN_VALUE);
        for (int x : nums1) {
            int pre = f[0];
            for (int j = 0; j < m; j++) {
                int tmp = f[j + 1];
                f[j + 1] = Math.max(Math.max(pre, 0) + x * nums2[j], Math.max(f[j + 1], f[j]));
                pre = tmp;
            }
        }
        return f[m];
    }
}

###cpp

class Solution {
public:
    int maxDotProduct(vector<int>& nums1, vector<int>& nums2) {
        int m = nums2.size();
        vector<int> f(m + 1, INT_MIN);
        for (int x : nums1) {
            int pre = f[0];
            for (int j = 0; j < m; j++) {
                int tmp = f[j + 1];
                f[j + 1] = max(max(pre, 0) + x * nums2[j], max(f[j + 1], f[j]));
                pre = tmp;
            }
        }
        return f[m];
    }
};

###go

func maxDotProduct(nums1, nums2 []int) int {
m := len(nums2)
f := make([]int, m+1)
for i := range f {
f[i] = math.MinInt
}
for _, x := range nums1 {
pre := f[0]
for j, y := range nums2 {
tmp := f[j+1]
f[j+1] = max(max(pre, 0)+x*y, f[j+1], f[j])
pre = tmp
}
}
return f[m]
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(nm)$,其中 $n$ 是 $\textit{nums}_1$ 的长度,$m$ 是 $\textit{nums}_2$ 的长度。
  • 空间复杂度:$\mathcal{O}(m)$。

专题训练

见下面动态规划题单的「§4.1 最长公共子序列(LCS)」。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

记忆化搜索->递推->空间优化

作者 yi-cheng-8i
2024年7月9日 10:28

灵神dp题单打卡

思路

定义$dfs(i,j) $,代表了以前$i$和$j$个数中$nums1$和$nums2$最大点积
(即$nums1[0...i]$和$nums2[0...j]$的最大点积)。

  • 如果选当前位置,那么算出当前位置点积为$nums1[i] * nums2[j]$,同时看前面位置的最大点积$dfs(i - 1,j - 1)$是否大于0,如果小于0的话,越加越小,不如不要,跟0取max就可以实现。状态方程如下:
    $$dfs(i,j) = max(dfs(i - 1,j - 1),0) + nums1[i] * nums2[j]$$
  • 如果不选当前位置,也就是跳过一格,状态方程如下:
    $$dfs(i,j) = max(dfs(i - 1,j),dfs(i,j - 1))$$

递推是记忆化搜索1: 1翻译而来,而空间优化则是在二维的基础上,观察值如何转移的优化的。具体可见b站灵神算法精讲中的内容,有总结。

Code

###C++

class Solution {
public:
    int maxDotProduct(vector<int>& nums1, vector<int>& nums2) {
        int n = nums1.size(),m = nums2.size();
        vector<vector<int>> memo(n,vector<int>(m,INT_MIN));
        function<int(int,int)> dfs = [&](int i,int j) -> int{
            if(i < 0 || j < 0) return INT_MIN;
            if(memo[i][j] != INT_MIN) return memo[i][j];
            //选
            memo[i][j] = max(dfs(i - 1,j - 1),0) + nums1[i] * nums2[j];
            memo[i][j] = max({memo[i][j],dfs(i - 1,j),dfs(i,j - 1)});
            return memo[i][j];
        };
        return dfs(n - 1,m - 1);
    }
};

###cpp

class Solution {
public:
    int maxDotProduct(vector<int>& nums1, vector<int>& nums2) {
        int n = nums1.size(),m = nums2.size();
        vector<vector<int>> memo(n,vector<int>(m,INT_MIN));
        //新一点的递归写法
        auto dfs = [&](this auto&& self,int i,int j) -> int{
            if(i < 0 || j < 0) return INT_MIN;
            if(memo[i][j] != INT_MIN) return memo[i][j];
            //选
            memo[i][j] = max(self(i - 1,j - 1),0) + nums1[i] * nums2[j];
            memo[i][j] = max({memo[i][j],self(i - 1,j),self(i,j - 1)});
            return memo[i][j];
        };
        return dfs(n - 1,m - 1);
    }
};

###c++

    int maxDotProduct(vector<int>& nums1, vector<int>& nums2) {
        int n = nums1.size(),m = nums2.size();
        vector<vector<int>> f(n + 1,vector<int>(m + 1,INT_MIN));
        for(int i = 0;i < n;i++){
            for(int j = 0;j < m;j++){
                f[i + 1][j + 1] = max({max(f[i][j],0) + nums1[i] * nums2[j],
                                    f[i + 1][j],f[i][j + 1]});
            }
        }
        return f[n][m];
    }

###c++

    int maxDotProduct(vector<int>& nums1, vector<int>& nums2) {
        int n = nums1.size(),m = nums2.size();
        vector<int> f(m + 1,INT_MIN);
        for(int i = 0;i < n;i++){
            f[0] = INT_MIN;//这里相当于初始化第一列。
            int pre = INT_MIN;
            for(int j = 0;j < m;j++){
                int t = f[j + 1];
                f[j + 1] = max({max(pre,0) + nums1[i] * nums2[j],
                                    f[j],f[j + 1]});
                pre = t;
            }
        }
        return f[m];
    }

c++ 动态规划 易懂

作者 smilyt_
2020年5月24日 12:09

题意

本题就是求两个子序列点积的最大值。题意很明确,直接说解法。
很显然这题用dp做,但是状态转移方程怎么写,dp[i][j]代表什么意思,依然是一个值得写一下的问题。

首先我们考虑dp[i][j]代表什么意思。

dp[i][j]的含义

第一种想法:
dp[i][j]的含义是以nums1[i]和nums2[j]结尾的子序列的最大点积。
第二种想法:
dp[i][j]的含义是到nums1[i]和nums2[j]为止的子序列的最大点积。

这两种是不一样的:
第一种想法一定要包含nums1[i]和nums2[j],因为以它们结尾。
但是第二种想法就没有这个限制,以谁结尾无所谓,最主要是大。

我们应该使用第二种,具体原因是因为状态转移方程。

状态转移方程

第一种想法的状态转移方程怎么写呢?

dp[i][j]=max(nums1[i]*nums2[j] , nums1[i]*nums2[j]+ maxVal);  

首先我们知道nums1[i]*nums2[j]这个值在第一种想法中是一定要有的。
接下来我们可以选择只有这两项或者包含前面的子序列点积最大值:
假如只有这两项,那么就什么都不加;假如也包含前面的就加上前面子序列点积的最大值maxVal。

来算一下时间复杂度:
首先算n^2个dp值
在每次dp计算中都要找到前面子序列点积的最大值,又要花费n^2的时间
所以时间复杂度为n^4,(500)^4是超时的

第二种想法的状态转移方程怎么写呢?
第二种可以选择nums1[i]和nums2[j],所以我们可以通过这个来写状态转移方程:
(其实对于子序列的很多dp题来讲,都可以使用选不选来写状态转移方程)

1.选择nums1[i]和nums2[j]

1.1不选择前面的 dp[i][j]=nums1[i]*nums2[j]
1.2也选择前面的 dp[i][j]=max(dp[i][j],nums1[i]*nums2[j]+dp[i-1][j-1])
因为dp[i][j]是截止到nums1[i]和nums2[j]中的最大点积,所以只需要dp[i-1][j-1]就可以了  
事实上从这里可以看出想法一就是想法二的情况之一

2.选择nums1[i],不选择nums2[j]

等价于dp[i][j-1]
dp[i][j]=max(dp[i][j],dp[i][j-1])

3.不选择nums1[i],选择nums2[j]

等价于dp[i-1][j]
dp[i][j]=max(dp[i][j],dp[i-1][j])

4.???

聪明的你肯定知道了
状态方程你来写吧:dp[i][j]=max(dp[i][j],???)

代码

###cpp


class Solution {
public:
    int maxDotProduct(vector<int>& nums1, vector<int>& nums2) {
        int sz1=nums1.size(),sz2=nums2.size();
        vector<vector<int>> dp(sz1+1,vector<int>(sz2+1,-1e8));

        for(int i=1;i<=sz1;i++){
            for(int j=1;j<=sz2;j++){
                //1.1
                dp[i][j]=nums1[i-1]*nums2[j-1];
                //1.2
                dp[i][j]=max(dp[i][j],nums1[i-1]*nums2[j-1]+dp[i-1][j-1]);
                //2
                dp[i][j]=max(dp[i][j],dp[i][j-1]);
                //3
                dp[i][j]=max(dp[i][j],dp[i-1][j]);
                //4
                dp[i][j]=max(dp[i][j],dp[i-1][j-1]);
            }
        }
        return dp[sz1][sz2];
    }
};

哦,对,求个赞

有疑问评论区可以交流,看到一定回

昨天 — 2026年1月7日技术

零基础 Coze + 前端 Vue3 边玩边开发:宠物冰球运动员生成器

2026年1月7日 21:52

引言

现如今 AI 应用开发变得越来越“平民化”,即使你不懂任何深度学习,也能通过“玩”一些 AI 开发平台来做出一些有趣又智能的小工具。而这其中不得不提的就是✨字节跳动推出的一站式AI应用开发平台 Coze 了

那为什么要选择 Coze 呢?那是因为其强大的低代码开发能力,即使是小白也可通过可视化界面“搭积木”一样编排工作流——比如上传图片、调用大模型、生成新图像等等,并且全程无需写复杂的后端或算法代码,极大降低了 AI 集成门槛💡。

拥有了工作流后,我们就可以使用优秀的现代前端框架 Vue3 ,通过其简洁的语法、响应式数据和组件化思想,让页面交互开发变得高效又直观。不仅向用户展示我们的开发成果,也要“接住”用户操作,最后再把结果展示出来。

本文就以“宠物变冰球运动员”为例,联合 Coze 的“AI 能力”与 Vue3 的“交互表现力” 来手把手教你如何使用这两项技术,从零搭建一个完整的 AI 应用。

项目简介

本项目是一个基于 Vue3 + Coze AI 工作流 的轻量级 Web 应用,实现用户上传一张宠物照片,选择队服编号、颜色、担任的位置、持杆手以及艺术风格,即可生成该宠物化身“冰球运动员”的图像。

核心功能:

  • 文件上传并预览
  • 用户配置参数(队服、风格等)
  • 调用 Coze 工作流进行 AI 处理
  • 返回生成的图像并展示

📂 项目结构说明

project-root/
├── src/
│   ├── components/
│   └── App.vue          # 主页面组件
├── public/
│   └── index.html
├── vite.config.js
└── package.json

我们只用了一个主文件 App.vue 来完成全部功能,适合快速原型开发。

🤖 Coze 平台开发详解(低代码)

整体流程概述:

  1. 开始节点:接收所有输入数据。
  2. 图像理解节点:分析照片内容,提取宠物图片的关键特征。
  3. 特征提取节点:将特征转化为结构化的描述。
  4. 代码节点:处理用户提供的参数或生成默认值来描述一只宠物作为“冰球运动员”的外观和特性
  5. 图像生成节点:结合用户选择和图片特征来生成需要展现给用户的图片
  6. 结束节点:将生成的新图像展示给用户。

从输入图片开始,经过图像理解、特征提取、代码处理与图像生成,最终输出结果的完整流程

image.png


一、开始节点:

功能:流程入口,接收用户的原始输入数据,启动后续流程。

操作演示

动画.gif

输入参数

参数名 类型 是否必填 默认值 描述
picture Image
style String 写实 生成照片的艺术风格
uniform_number Number 10 运动员的号码
uniform_color String 球衣颜色
position Number 整数枚举,0、1、2,分别为守门员、前锋、后卫,默认随机
shooting_hand Number 持杆手,整数枚举0、1,分别为左手、右手,默认随机

image.png


二、图像理解节点:imgUnderstand

功能:使用AI模型分析图像内容。

操作演示

动画1.gif

输入

  • text:这应该是一张宠物图片,请详细描述宠物的外貌特征。
  • url:选择从开始取到的图片

image.png

输出:通过“查看示例”可以查看输出效果

  • author:作者信息
  • content:图像描述文本
  • msg:返回消息

image.png


三、特征提取节点:

功能:对文本进行语义分析,提取关键词或结构化信息。

操作演示

动画.gif

模型:豆包·1.5 · Pro · 32k(默认即可)

技能:无需配置技能

输入:连接图像理解节点会自动配置 imgUnderstandcontent 字段为 input 中的变量值

动画1.gif系统提示词:为对话提供系统级指导,在此之中可以使用{{变量名}}等方式引用变量(此处我们导入文字提示词即可)

你是动物学家,负责从动物描述中提取出该动物(主要是外表)里最有独特性的特征,例如特征的肤色、表情、神态、动作等等。

用户提示词:向模型提供用户指令(此处我们导入输入变量input

{{input}}

输出output(默认即可)


四、代码节点:

功能:执行自定义代码逻辑,这里我们需要处理用户提供的参数或生成默认值

操作演示

动画.gif

输入:从开始节点里面导入shooting_handstyleuniform_numberuniform_colorposition

image.png

代码逻辑

const random = (start: number, end: number) => {
    // 生成一个 [0, 1) 之间的随机数
    const p = Math.random();
    // 线性插值 + 向下取整
    // start * (1 - p) + end * p 只会让数据居于[start, end)之间
    // 再向下取整使数据只为整数并且小于end
    return Math.floor(start * (1 - p) + end * p);
}

//对用户传入的参数进行标准化处理和默认值填充,以便后续用于图像生成 prompt 的构建
async function main({ params }: Args): Promise<Output> {
    if (params.position == null) params.position = random(0, 3);
    if (params.shooting_hand == null) params.shooting_hand = random(0, 2);

    const style = params.style || '写实';
    const uniform_number:string = (params.uniform_number || 10).toString();
    const uniform_color = params.uniform_color || '红';
    const position = params.position  == 0 ? '守门员': (params.position == 1 ? '前锋': '后卫');
    const shooting_hand = params.shooting_hand == 0 ? '左手': '右手';
    const empty_hand = params.shooting_hand ? '左手': '右手';

    // 构建输出对象
    const ret = {
        style,
        uniform_number,
        uniform_color,
        position,
        shooting_hand,
    };

    return ret;
}

输出

image.png


五、图像生成节点:

功能:根据描述生成新图像。

操作演示

动画1.gif

模型设置:选择通用即可,比例按照自身喜好调节

参考图:添加参考图,模型选择“形象一致”,参考图选择开始节点中的 picture,程度调为 0.7

程度:决定了参考图对最终生成图像的影响程度。数值范围通常是从0到1,其中0表示完全不考虑参考图,而1表示尽可能地模仿参考图。

输入

  • description:图片描述,导入imgUnderstand节点中的content
  • 代码节点里面导入处理后的shooting_handstyleuniform_numberuniform_colorposition
  • details:详细信息,导入特征提取节点中的output

image.png

提示词:生成内容的提示词,分为两类

  • 正向提示词:引导生成内容
用动物的形象和特征,将该动物**拟人**为一名宠物儿童冰球员,生成{{style}}风格的冰球球员照片,球员身穿
{{uniform_color}}色队服,佩戴同色的冰球头盔,队服号码为{{uniform_number}}号,球员位置是{{position}},
用{{shooting_hand}}握着球杆,另一只手空着。该照片图像风格为{{style}}。

# 动物形象描述
{{description}}

# 独特外貌特征
{{details}}

# 注意
- 照片中应强化动物独特的外貌特征,以增加辨识度
- 如果球员位置是守门员,画面中应该有冰球球门
  • 反向提示词:排除不良或无关元素
球员双手各握一根球杆
球员未佩戴头盔
球员吃东西
画面中出现除了冰球之外的其他球类
地点不在冰球赛场
球员四足站立

输出

  • data:生成图像的二进制数据或URL
  • msg:状态信息

六、结束节点:

功能:工作流的最终节点,返回工作流运行后的结果信息。

操作演示

动画.gif

输出变量:连接图像生成节点

  • output:最终结果,变量值为图像生成节点中的data

回答内容:编辑智能体回复的内容,我们这里不修改直接输出图片(不需要流式输出)

{{output}}

流式输出:回复内容中的大语言模型的生成内容将会逐字流式输出;关闭后,回复内容将全部生成后一次性输出


测试:

在连接好工作流后,我们当然需要进行测试,以保证节点中没有发生错误

操作演示

动画1.gif

后话:

当然,在你测试无误之后,可以点击右上角的发布来向别人分享你第一次创作的工作流。

发布之后,在你资源库中的工作流中就能找到你所发布的资源了。

💻 前端实现详解(Vue3 + JavaScript)

一、 <template> —— 视觉层布局

首先我们确定组件的主要功能为---用户可以上传一张图片,并根据不同的参数(如队服编号、颜色等)进行个性化设置。

所以我们的UI布局,包括两块区域:输入区域(用于上传图片和选择参数)和 输出区域(展示生成的结果)。

<template>
  <div class="container">
    <!-- 输入区域 -->
    <div class="input">
      <div class="file-input">
        <input type="file" ref="uploadImage" accept="image/*" @change="updateImageData" required />
      </div>
      <img :src="imgPreview" alt="" v-if="imgPreview" />
      <!-- 用户选择参数区域 -->
      <div class="settings">
        <div class="selection">
          <label>队服编号:</label>
          <input type="number" v-model="uniform_number" />
        </div>
        <div class="selection">
          <label>队服颜色:</label>
          <select v-model="uniform_color">
            <option value="红"></option>
            <option value="蓝"></option>
            <option value="绿">绿</option>
            <option value="白"></option>
            <option value="黑"></option>
          </select>
        </div>
      </div>
      <div class="settings">
        <div class="selection">
          <label>位置:</label>
          <select v-model="position">
            <option value="0">守门员</option>
            <option value="1">先锋</option>
            <option value="2">后卫</option>
          </select>
        </div>
      </div>
      <div class="selection">
        <label>持杆:</label>
        <select v-model="shooting_hand">
          <option value="0">左手</option>
          <option value="1">右手</option>
        </select>
      </div>
      <div class="selection">
        <label>风格:</label>
        <select v-model="style">
          <option value="写实">写实</option>
          <option value="乐高">乐高</option>
          <option value="国漫">国漫</option>
          <option value="日漫">日漫</option>
          <option value="油画">油画</option>
          <option value="涂鸦">涂鸦</option>
          <option value="素描">素描</option>
        </select>
      </div>
      <div class="generate">
        <button @click="generate">生成</button>
      </div>
    </div>
    <!-- 输出区域 -->
    <div class="output">
      <div class="generated">
        <img :src="imgUrl" alt="" v-if="imgUrl">
        <div v-if="status">{{ status }}</div>
      </div>
    </div>
  </div>
</template>

1、输入部分(input):

<input type="file" ref="uploadImage" accept="image/*" @change="updateImageData" required />

这段代码实现了 让用户选择本地文件并且触发预览更新

  • type="file":声明这是一个文件上传控件,点击后会弹出系统文件选择对话框。
  • accept="image/*"限制用户只能选择图片文件,防止无效输入。 
  • @change="updateImageData":当用户选中一张图片后,会立即触发 updateImageData 方法(用于将图片文件转换为可以直接预览的 URL)
  • ref="uploadImage":为这个 input 元素注册一个 引用标识,方便在 <script setup> 中通过声明一个相同名称的 ref 变量 来直接访问该 DOM 元素。
  • required: 表示该字段是必填项
对比 refv-model:
  • v-model:同步“数据值”,并且可以自动更新(关心的是“值”本身
  • ref:获取“DOM元素”,而且需要手动更新(需要访问 DOM 的原生能力

⚠️并且在 <input type="file">中 无法使用 v-model

因为浏览器出于安全考虑,不允许 JS 设置 file input 的值,只能读取。所以必须用 ref 来读取用户选中的文件。

<img :src="imgPreview" alt="" v-if="imgPreview" />

在用户上传图片后,需要及时给予用户反馈(让用户知道自己行为驱动了页面,而不是毫无作用),所以需要展示预览图,让用户知道上传图片成功了。

  • :src="imgPreview":动态绑定响应式变量 imgPreview,用于自动更新图片。
  • v-if="imgPreview":条件渲染,只有当用户成功上传后才能渲染<img>元素,避免未上传时显示空白占位图。

2、用户选择参数部分(settings):

  <div class="selection">
    <label>队服颜色:</label>
    <select v-model="uniform_color">
      <option value="红"></option>
      ...
    </select>
  </div>

响应式的选择器,以便于用户选择参数

  • <label>队服颜色:</label>:提供文字说明,告诉用户这个下拉框的作用
  • <select>:HTML 的下拉选择控件
  • v-model="uniform_color"双向绑定 uniform_color 用于同步用户选择数据
  • <option value="红">红</option>:每个 <option> 代表一个可选项,该选项被选中时,赋给 uniform_color 的实际值就为红

3、输出部分(output):

<img :src="imgUrl" alt="" v-if="imgUrl">

同上,绑定的响应式变量 imgUrl 用于返还 Coze 工作流生成后的图片

<div v-if="status">{{ status }}</div>

显示当前操作的状态提示信息,为用户提供及时的反馈

  • v-if="status":只有当 status 有内容时才渲染
  • {{ status }}:将 status 的文本内容插入到页面中

二、<script setup> —— 业务逻辑核心

基于业务需求,我们的开发流程总体为:定义响应式状态(数据层) --> 实现图片预览(本地交互) --> 实现文件上传到 Coze(前置依赖) --> 调用工作流生成图像(核心业务) --> 环境变量与 API 配置(支撑信息)

<script setup>
import { ref, onMounted } from 'vue'

// --------------- 定义响应式状态 -------------------
const uniform_number = ref(10);
const uniform_color = ref('红');
const position = ref(0);
const shooting_hand = ref(0);
const style = ref('写实')
// 数据状态
const status = ref('');     // 反馈用户当前操作状态(空 / 上传中 / 生成中 / 错误)
const imgUrl = ref('');     // 用于存储最终生成的图片 URL
const imgPreview = ref(''); // 用于本地预览用户上传的原图

// --------------- 图片预览模块 -------------------
const uploadImage = ref(null);

onMounted(() => {
  console.log(uploadImage.value)
})
const updateImageData = () => {
  const input = uploadImage.value;
  if (!input.files || input.files.length === 0) {
    return;
  }
  const file = input.files[0];
  console.log(file);
  const reader = new FileReader();
  reader.readAsDataURL(file); 
  reader.onload = (e) => { 
    imgPreview.value = e.target.result;
  }
}


// --------------- 大厂常用业务请求 ----------------
const patToken = import.meta.env.VITE_PAT_TOKEN;
const uploadUrl = 'https://api.coze.cn/v1/files/upload';
const uploadFile = async () => {
  const formData = new FormData(); 
  const input = uploadImage.value;
  if (!input.files || input.files.length <= 0) return;
  formData.append('file', input.files[0]);

  const res = await fetch(uploadUrl, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${patToken}`
    },
    body: formData
  })

  const ret = await res.json();
  console.log(ret);

  if( ret.code !== 0) {
    status.value = ret.msg; 
    return
  }
  return ret.data.id;
}

// --------------- 生成图片模块 -------------------
const workflowUrl = 'https://api.coze.cn/v1/workflow/run';
const workflow_id = 'XXXXXXX';

const generate = async () => {
  status.value = "图片上传中..."
  const file_id = await uploadFile();
  if (!file_id) return;
  status.value = "图片上传成功,正在生成...";

// workflow 调用 
  const parameters = {
    picture: JSON.stringify({
      file_id
    }),
    style: style.value,
    uniform_color: uniform_color.value,
    uniform_number: uniform_number.value,
    position: position.value,
    shooting_hand: shooting_hand.value,
  }

  const res = await fetch(workflowUrl, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${patToken}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      workflow_id,
      parameters
    })
  });
  const ret = await res.json();
  if( ret.code !== 0) {
    status.value = ret.msg;
    return;
  }

  const data = JSON.parse(ret.data);
  console.log(data);
  status.value = '';
  imgUrl.value = data.data;
}
</script>

1、定义响应式状态(数据层):

// 用户选择参数,并且设定初始值
const uniform_number = ref(10);
const uniform_color = ref('红');
const position = ref(0);
const shooting_hand = ref(0);
const style = ref('写实')

// 数据状态
const status = ref(''); // 空 -> 上传中 -> 生成中 -> 生成成功
const imgUrl = ref(''); // 生成图片url

2、图片预览功能(本地交互):

const uploadImage = ref(null);

onMounted(() => {
  console.log(uploadImage.value)
})
const updateImageData = () => {
  const input = uploadImage.value;
  if (!input.files || input.files.length === 0) {
    return;
  }
  const file = input.files[0];
  // console.log(file);
  const reader = new FileReader();
  reader.readAsDataURL(file); 
  reader.onload = (e) => { 
    imgPreview.value = e.target.result;
  }
}
定义响应式引用
const uploadImage = ref(null);

标记模板中的 <input type="file">元素,未挂载前为 null,便于访问该DOM元素

生命周期钩子:组件挂载时执行的操作
// null -> DOM对象 (变化)
onMounted(() => {
  console.log(uploadImage.value)
})

输出 uploadImage.value 到控制台,便于检查此时是否已经正确绑定了 <input> 元素。如果绑定成功,uploadImage.value 就指向上面的 <input> 元素。

更新图片数据方法
// 用户选择文件后调用
const updateImageData = () => {
  const input = uploadImage.value;
  // console.log(uploadImage.value.files); 
  if (!input.files || input.files.length === 0) {
    return;
  }
  const file = input.files[0];
  // console.log(file);
  const reader = new FileReader();
  reader.readAsDataURL(file); 
  reader.onload = (e) => { 
    imgPreview.value = e.target.result;
  }
}
  • if (!input.files || input.files.length === 0) return;:判断用户上传的文件中是否至少包含一个文件
  • const file = input.files[0];:取出用户上传的第一个文件(因为不知道用户会上传多少文件,但是我们单次只能处理一个)
  • reader.readAsDataURL(file);:用新创建的 FileReader 实例上的 readAsDataURL 方法将文件转换为 Base64 编码的字符串(这种格式可以直接作为图像的URL使用)
  • reader.onload = (e) => { imgPreview.value = e.target.result; }:onload 是一个事件处理器(回调函数),会自动触发 load 事件,这里也就是事件对象 e,而e.target指的是触发该事件的对象(也就是reader),e.target.result就是转换为 Base64 编码的字符串,并且赋值给本地预览的响应式数据 imgPreview,这样页面上就会自动更新

⚠️ 注意:文件读取是异步的,不能直接写 let result = reader.readAsDataURL(file),必须用回调或 Promise。

动画.gif


3、文件上传至 Coze 云服务:

const patToken = import.meta.env.VITE_PAT_TOKEN;
const uploadUrl = 'https://api.coze.cn/v1/files/upload';
// 先上传到coze服务器 
const uploadFile = async () => {
  // 创建一个空表单用于提交数据
  const formData = new FormData(); // FormData专门用于构建form-data格式数据的对象,常用于传输文件
  const input = uploadImage.value;
  if (!input.files || input.files.length <= 0) return;
  formData.append('file', input.files[0]);

  // 向 coze 发送http请求 上传
  const res = await fetch(uploadUrl, {
    method: 'POST', 
    headers: {
      'Authorization': `Bearer ${patToken}`
    },
    body: formData
  })

  // 等待返还内容(异步),并转换为json格式
  const ret = await res.json();
  // console.log(ret);

  // 错误判断
  if( ret.code !== 0) {
    status.value = ret.msg; 
    return;
  }
  return ret.data.id;
}
  • const patToken = import.meta.env.VITE_PAT_TOKEN;:获取认证令牌,在项目根目录中创建.env文件,在其中放置你的私有访问令牌。

image.png

  • const uploadUrl = 'https://api.coze.cn/v1/files/upload';:Coze 官方提供的文件上传 API 地址
  • formData.append('file', input.files[0]);:将用户选中的第一个文件附加到表单字段名为 file 的字段上

⚠️ 注意:Coze API 要求字段名必须是 'file',否则会报错

  • const res = await fetch(uploadUrl, {...}):向 Coze 上传文件
响应结构(Coze API 规范):
  const ret = await res.json();

  // 错误判断
  if( ret.code !== 0) {
    status.value = ret.msg; 
    return;
  }
  return ret.data.id;

Coze 的 API 通常返回如下 JSON 结构:

{
  "code": 0,
  "msg": "success",
  "data": {
    "id": "file-abc123xyz",   // ← 我们需要的 file_id
    "name": "cat.jpg",
    "size": 123456
  }
}
  • ret.code !== 0:判断是否出错(code === 0 表示成功)。
  • status.value = ret.msg:将错误信息显示给用户。
  • return ret.data.id:成功时返回 file_id(字符串),供后续工作流调用使用。

注: 这个 file_id 是 Coze 系统内部对文件的唯一引用,后续在调用工作流时,只需传递这个 ID,无需再传整个文件。


4、触发 Coze 工作流生成图像(核心业务):

const workflowUrl = 'https://api.coze.cn/v1/workflow/run';
const workflow_id = 'XXXXXXX';

const generate = async () => {
  status.value = "图片上传中..."
  const file_id = await uploadFile();
  if (!file_id) return;
  status.value = "图片上传成功,正在生成...";

// workflow 调用 
  const parameters = {
    picture: JSON.stringify({
      file_id
    }),
    style: style.value,
    uniform_color: uniform_color.value,
    uniform_number: uniform_number.value,
    position: position.value,
    shooting_hand: shooting_hand.value,
  }

  const res = await fetch(workflowUrl, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${patToken}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      workflow_id,
      parameters
    })
  });
  const ret = await res.json();
  if( ret.code !== 0) {
    status.value = ret.msg;
    return;
  }

  const data = JSON.parse(ret.data);
  console.log(data);
  status.value = '';
  imgUrl.value = data.data;
}
  • const workflowUrl = 'https://api.coze.cn/v1/workflow/run';:Coze 官方提供的 工作流执行 API 地址
  • const workflow_id = 'XXXXXXX';:你在 Coze 控制台创建的工作流的唯一 ID(在 Coze Bot 或 Workflow 编排界面可找到)
  • const file_id = await uploadFile();:取到返回的 file_id

为什么先上传?
Coze 的工作流无法直接处理前端的本地文件或 Base64,必须使用之前通过接口上传后返回的 file_id

解析最终图片 URL
const data = JSON.parse(ret.data);
console.log(data);
status.value = '';
imgUrl.value = data.data;

❗ 为什么需要两次 JSON.parse

这是 Coze 工作流 API 的特殊设计

  • 第一层 ret.data 是一个 JSON 字符串(不是对象!)
    ret.data === '{"data":"https://cdn.coze.com/generated.jpg"}'
    
  • 所以必须用 JSON.parse(ret.data) 得到真正的对象:
    const data = { data: "https://cdn.coze.com/generated.jpg" };
    

最终图片 URL 在 data.data 中。

📌 这是因为 Coze 工作流的输出通常被封装为字符串,确保兼容性


三、CSS 样式优化

    .container {
      display: flex;
      flex-direction: row;
      align-items: start;
      justify-content: start;
      height: 100vh;
      font-size: .85rem;
    }

    .input {
      display: flex;
      flex-direction: column;
      min-width: 330px;
    }

    .output {
      margin-top: 10px;
      min-height: 300px;
      width: 100%;
      text-align: left;
    }

    .generated {
      width: 400px;
      height: 400px;
      border: solid 1px black;
      position: relative;
      display: flex;
      justify-content: center;
      align-items: center;
    }

    .output img {
      width: 100%;
    }

效果演示:

image.png

📚 学习资源推荐

类别 推荐内容
Vue3 Vue 官方文档
Coze Coze 开发者中心
REST API MDN Fetch API 文档
前端工程化 Vite、Webpack、JavaScript

📝 最后

这个项目不仅是一个简单的“AI图像生成器”,更是现代 Web 开发的一个缩影:前端负责交互与体验,AI 提供智能能力,API 连接两者😊,这也是当下的趋势:无代码/低代码 + 可视化 AI 编排 + 前端集成

AI 原生应用」的崛起

  • 传统 AI 开发:需要算法工程师 + 后端 + 前端,周期长、成本高。

  • 新范式(Coze 代表):

    • 业务人员/前端开发者 通过拖拽工作流,组合大模型、插件、知识库;
    • 前端直接调用工作流 API,像调用普通接口一样获取 AI 结果;
    • 无需维护后端服务,Coze 托管执行环境。

这就是  “AI as a Service”(AI 即服务)

GDAL 实现数据空间查询

作者 GIS之路
2026年1月7日 21:14

^ 关注我,带你一起学GIS ^

前言

在GIS开发中,空间查询和属性查询都是常见的基础操作,是每一个GISer都要掌握的必备技能。实现高效的数据查询功能可以提升用户体验,提升数据可视化效率。

在之前的文章中讲了如何使用GDAL或者ogr2ogr工具将txt以及csv文本数据转换为Shp格式,本篇教程在之前一系列文章的基础上讲解如何使用GDAL实现数据空间查询功能

如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 空间查询

GDAL中,有两个图层方法可以用于实现空间查询,分别是SetSpatialFilterSetSpatialFilterRect。此种查询方式为直接在源数据上操作,返回结果为查询图层。

"""
参数
    -geom:用于几何查询的几何对象
"""
SetSpatialFilter(geom)

"""
参数:
    -minx:最小x坐标
    -miny:最小y坐标
    -maxx:最大x坐标
    -maxy:最大y坐标
"""
SetSpatialFilterRect(minx,miny,maxx,maxy)

传入None值时重置查询。

sourceLayer.SetSpatialFilter(None)

在进行正式查询前和以前的文章一样,首先获取数据驱动,添加数据源,获取图层数据。定义一个方法SpatialFilter用于实现空间查询,该方法接受两个参数,一个sourcePath传递源数据图层路径,另一个selectPath用于定义查询图层路径。

"""
说明:图层属性过滤
参数:
    -sourcePath:待查询 Shp 文件路径
    -selectPath:查询 Shp 文件路径
"""
def SpatialFilter(sourcePath,selectPath):

在以下代码中完成图层数据的读取操作。

# 注册所有驱动
ogr.RegisterAll()

# 添加数据驱动
shpDriver = ogr.GetDriverByName("ESRI Shapefile")

checkFilePath(sourcePath,shpDriver)
checkFilePath(selectPath,shpDriver)

# 打开数据源
sourceDs = shpDriver.Open(sourcePath)
selectDs = shpDriver.Open(selectPath)

hasDs = sourceDs and selectDs

if hasDs is None:
    print("数据源打开异常,请检查路径!")
    return False

# 获取图层
sourceLayer = sourceDs.GetLayer(0)
selectLayer = selectDs.GetLayer(0)

下面对几何查询以及矩形查询两种实现方式进行介绍。

2.1. 几何查询

通过图层方法GetNextFeature获取第一个要素对象。

# 获取查询几何对象
queryFeat = selectLayer.GetNextFeature()
queryGeom = queryFeat.GetGeometryRef()

print(f"查询要素id:{queryFeat.GetField('Id')}")
print(f"查询几何对象:{queryGeom}")

将几何对象传入SetSpatialFilter方法进行空间过滤。

# 空间过滤
sourceLayer.SetSpatialFilter(queryGeom)

查询完成之后传入None值结束查询。

# 结束查询
sourceLayer.SetSpatialFilter(None)

以下为几何查询部分代码。

# 获取查询几何对象
queryFeat = selectLayer.GetNextFeature()
queryGeom = queryFeat.GetGeometryRef()

print(f"查询要素id:{queryFeat.GetField('Id')}")
print(f"查询几何对象:{queryGeom}")

# 获取要素数量
featureCount = sourceLayer.GetFeatureCount()
print(f"所有要素数量:{featureCount}")

# 空间过滤
sourceLayer.SetSpatialFilter(queryGeom)

queryFeatCount = sourceLayer.GetFeatureCount()
print(f"查询要素数量:{queryFeatCount}")

# 结束查询
sourceLayer.SetSpatialFilter(None)
finalFeatCount = sourceLayer.GetFeatureCount()
print(f"重置查询后要素数量:{finalFeatCount}")

print("n~~~~~~~方式一:结束几何查询~~~~~~~")

若有兴趣,还可以创建自定义几何对象进行空间查询。

# 自定义Geometry查询对象
customGeom = ogr.Geometry(ogr.wkbPolygon)
ringGeom = ogr.Geometry(ogr.wkbLinearRing)

ringGeom.AddPoint(102.884350,32.501570)
ringGeom.AddPoint(105.025865,74.974949)
ringGeom.AddPoint(50.417235,55.701314)
ringGeom.AddPoint(50.417235,32.501570)

ringGeom.CloseRings()
customGeom.AddGeometry(ringGeom)

# 获取要素数量
featureCount = sourceLayer.GetFeatureCount()
print(f"所有要素数量:{featureCount}")

# 空间过滤
sourceLayer.SetSpatialFilter(customGeom)

queryFeatCount = sourceLayer.GetFeatureCount()
print(f"查询要素数量:{queryFeatCount}")

# 结束查询
sourceLayer.SetSpatialFilter(None)
finalFeatCount = sourceLayer.GetFeatureCount()
print(f"重置查询后要素数量:{finalFeatCount}")

print("n~~~~~~~方式一:结束几何查询~~~~~~~")

如下为几何查询输出结果:

如下为该要素在ArcGIS中查询显示结果。

2.2. 矩形查询

矩形查询主要调用SetSpatialFilterRect方法,传入x、y坐标即可。

# 获取要素数量
featureCount2 = sourceLayer.GetFeatureCount()
print(f"所有要素数量:{featureCount2}")

# 空间过滤
sourceLayer.SetSpatialFilterRect(-16.9994,23.1235,42.9111,49.5852,)
queryFeatCount2 = sourceLayer.GetFeatureCount()

如下为矩形查询输出结果:

3. 注意事项

windows开发环境中同时安装GDALPostGIS,其中投影库PROJ的环境变量指向PostGIS的安装路径,在运行GDAL程序时,涉及到要素、几何与投影操作时会导致异常。

具体意思为GDAL不支持PostGIS插件中的投影库版本,需要更换投影库或者升级版本。

RuntimeError: PROJ: proj_identify: D:Program FilesPostgreSQL13sharecontribpostgis-3.5projproj.db contains DATABASE.LAYOUT.VERSION.MINOR = 2 whereas a number >= 5 is expected. It comes from another PROJ installation.

解决办法为修改PROJ的环境变量到GDAL支持的版本或者在GDAL程序开头添加以下代码:

os.environ['PROJ_LIB'] = r'D:\Programs\Python\Python311\Libsite-packages\osgeo\data\proj'

OpenLayers示例数据下载,请在公众号后台回复:ol数据

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

GDAL 实现数据属性查询

GDAL 实现创建几何对象

GDAL 实现自定义数据坐标系

GDAL 实现矢量数据读写

GDAL 数据类型大全

GDAL 实现 GIS 数据读取转换(全)

别再被 TS 类型冲突折磨了!一文搞懂类型合并规则

作者 hboot
2026年1月7日 18:40

之前学习了TypeScript的类型定义,我们都知道开发语言中的变量会有覆盖声明的情况,那么对于类型定义是不是也会有这种情况,那么该如何正确利用这种合并规则?在遇到多个类型定义的时候,我们又该如何处理?

了解类型合并规则,有助于我们定义类型,避免类型冲突。可以利用这种合并规则,更灵活的定义类型。

不同版本TypeScript,不同配置可能会导致合并差异,这里说明使用的版本为"typescript": "^5.9.3",开启了配置"strict": true,测试文件后缀为.ts.d.ts文件可能更为宽松。

同名同类型合并

TypeScript仅支持接口interface、命名空间namespace、函数声明function同名合并。在声明解析阶段即完成合并。早于其他类型引用处理,比如交叉/联合类型。

对于typeclassenum等定义的同名类型都不能合并。

interface 接口声明合并

同一作用域下的同名接口会自动完成合并,无需额外语法。

合并特性:

  • 属性、方法、索引签名均可合并。
  • 同名属性、方法必须类型兼容,否则编译器报错。
  • 同一作用域/模块下。
interface Animal {
  name: string;
}

interface Animal {
  age: number;
}

// 实例必须包含name和age属性
const dog: Animal = {
  name: "",
  age: 0,
};

同属性,不同类型不兼容,编译器报错。

interface Animal {
  name: string;
}
// ❌ 后续属性声明必须属于同一类型。属性“name”的类型必须为“string”,但此处却为类型“number”。
interface Animal {
  name: number;
}

同属性同类型,不同修饰符不兼容,编译器报错。

interface Animal {
  name: string;
}
// ❌ 不同修饰符不兼容,编译器报错
interface Animal {
  name?: string;
}

namespace 命名空间合并

同名的命名空间会自动合并内部导出的成员。仅export导出的成员会合并。

namespace Utils {
  export function getName() {
    return "hboot";
  }
}

namespace Utils {
  export function getAge() {
    return 18;
  }
}

Utils.getName();
Utils.getAge();

不允许同名成员导出。

namespace Utils {
  export function getName() {
    return "hboot";
  }
}

namespace Utils {
  // ❌ 成员已存在,不允许重复定义
  export const getName = () => {
    return 18;
  };
}

function 函数声明合并

函数同名合并,我们称之为函数重载。TS编译器会按照倒序匹配,也就是后声明函数重载优先级高。

函数重载最后一个函数声明必须实现内部逻辑,并且参数数量、参数类型和返回值类型必须兼容。

仅使用function声明的函数支持

function getName(name: string): string;
function getName(age: number): number;
function getName(nameOrAge: string | number): string | number {
    return nameOrAge;
}
getName("hboot");
getName(18);

interface接口中定义的方法也会形成重载。但是和普通的函数重载最后的函数实现参数、返回值类型定义有些不同。

interface Animal {
  getVal(name: string): string;
}
interface Animal {
  getVal(age: number): number;
}

// ❌ 这样写编译器会直接报错,提示不能将类型 "string | number" 无法分配给类型 "string"。
const dog: Animal = {
  getVal(value: string | number): string | number {
    return value;
  },
};

普通函数重载是满足其一即可;而接口中方法重载是必须精准匹配类型每一个类型。利用TS类型推导通过泛型参数锁定类型。

// 利用了类型的自动推导,通过泛型参数 T 锁定输入类型及返回类型
const dog: Animal = {
  getVal<T extends string | number>(value: T): T {
    return value;
  }
};

同名不同类型合并

同名不同类型的合并,主要是命名空间namespace + interface/class/function的合并,namespace可以提供静态属性、方法。

namespace+interface 合并

同名的namespace命名空间为interface接口扩展静态成员;接口提供类型约束。

interface Animal {
  name: string;
}

namespace Animal {
  export const age = 18;
  export function getName() {
    return "hboot";
  }
}

const dog: Animal = {
  name: "hboot",
};

Animal.getName();

同名的属性、方法不会冲突,因为Animal命名空间直接通过空间名访问;interface接口需要实例化后的实例访问。它们之间实际上只有名称是相同的,属性之间没有合并。

namespace+class 合并

同名的namespace命名空间为class类扩展静态成员;类声明必须在命名空间的声明必之前,命名空间不能声明类已有的成员。

class Animal {
  name: string;
  static age: number;
  constructor(name: string) {
    this.name = name;
  }
}
namespace Animal {
  export const name = "hboot";
  // ❌ 此处扩展静态成员 age 报错,类中已存在 age 静态成员
  export const age = 18;
  export function getName() {
    return "hboot";
  }
}

const dog: Animal = new Animal("admin");
Animal.getName();
// hboot
Animal.name;
// admin
dog.name;

namespace+function 合并

同名的namespace命名空间为function函数扩展静态成员。函数保持自身的可调用能力。

function speak(name: string) {
  return "Hello World! " + name;
}

namespace speak {
  // ❌ 此处无法覆盖 函数的 name 属性;name 是只读属性
  // ❌ 类型校验没有报错,但运行时因为只读而报错
  export const name = "hboot";
  export function getName() {
    return "hboot";
  }
}

speak("hboot");
speak.name;
speak.getName();

扩展的静态成员最好不要覆盖函数本身的属性,比如namelength等。这些只读属性无法被覆盖,在运行时会报错。

interface+class 合并

同名的interface接口为class类扩展实例成员。类继承接口的属性、方法,实例必须同时满足接口和类的约束。

合并特性:

  • 接口的必选属性,在类中必须显式实现,否则执行报错。
  • 同名属性,必须类型兼容,否则执行报错。
interface Animal {
  name: string;
  getName(): string;
}

class Animal {
  age: number;
  constructor(age: number, name: string) {
    this.age = age;
    this.name = name;
  }
  // 必须显示实现 接口 的方法成员
  getName() {
    return this.name;
  }
}

const dog: Animal = new Animal(18, "hboot");

dog.age;
// 类中需通过构造函数
dog.name;
// ❌ 如果类没有显示实现;智能提示存在方法,实际调用会报错。
dog.getName();

除了手动赋值扩展属性外,可以通过public修饰符自动生成

class Animal {
 
  constructor(age: number, public name: string) {
    this.age = age;
    // 无需手动赋值
    // this.name = name;
  }

  const dog: Animal = new Animal(18, "hboot");
  // ...
}

显示类型合并

上述的同名同类型、同名不同类型合并实际最终也是属性的合并。对于非同名属性合并则是扩展;同名属性则有一些合并规则。

对于不同类型之间的同名属性合并都有自己的规则,比如:interface+class 合并要求属性类型兼容;namespace+class 合并要求命名空间不能包含类已有的成员。

通过手动将一些类型合并到一个类型中,例如交叉类型&和联合类型|

交叉类型& 关系合并

交叉类型将多个类型合并为一个新类型。新类型必须满足所有类型约束。

合并特性:

  • 不同名属性合并为属性并集。保留属性修饰符。
  • 同名属性取兼容类型,对于修饰符?,存在必选时则属性必选;修饰符readonly,存在属性可修改时则属性可修改。
// 不兼容类型 never
type A = string & number;

// 不同名属性并集
type B = { name: string } & { age: number };

// 可选 ? 修饰符, 兼容类型 name 为必选属性
type C = { name?: string } & { name: string };

// 只读 readonly 修饰符, 兼容类型 age 为可修改
type D = { readonly age: number } & { age: number };

联合类型| 关系合并

将多个类型组合为一个新类型。新类型只需要满足其中一个类型约束。

合并特性:

  • 仅能访问公共属性。需通过类型守卫收窄类型后才能访问非公共属性。
  • 完全一致的类型自动去重。
type A = {
  name: string;
  age: number;
};

type B = {
  name: string;
  address: string;
};

type C = A | B;

// 满足其中一个类型约束
const c: C = {
  name: "hboot",
  age: 18,
};

// 访问非公共属性
function viewC(data: C) {
  if ("age" in data) {
    return data.age;
  }
  return data.address;
}

.d.ts中的声明合并

.d.ts文件和.ts文件的合并核心规则一致。在执行时机、作用域、编译行为上有些不一样。

  • .d.ts 文件中仅有类型声明,无实际代码实现,仅用于TS类型检验。所以不同于.ts文件。它会在类型校验阶段早期执行,优先合并全局/模块类型;而.ts文件是在编译阶段执行。

  • .d.ts类型优先级低,能被.ts文件显示类型声明覆盖。

  • .d.ts跨文件同名声明合并(无import/export)。.ts仅在同一个文件中同名声明合并。

declare

declare 主要作用存在性声明,告诉TypeScript编译器无需生成对应的代码。

  • 全局变量/函数声明,比如:外部加载的js文件,挂载到window上的变量。
  • 扩展已有类型(与同名接口/命名空间合并)
  • 声明模块(非TS模块,比如.css.png等静态资源)

declare 扩展已有类型合并规则与.ts文件的合并核心规则一致.

interface Animal {
  name: string;
  getName(): string;
}
declare interface Animal {
  age: number;
}

.ts 模块中没有import/export时,通过declare声明为全局作用域。如果存在import/export则需要通过declare global扩展全局作用域。

js中的using声明

2026年1月7日 18:25

一、using 声明简介

usingECMAScript 2023(ES14) 引入的一项新语法,用于自动管理资源的生命周期
它的主要目标是简化“资源使用完后自动释放”的场景,例如文件句柄、数据库连接、锁等。 相关提案可见:github.com/tc39/propos…

📜 语法结构:

using 变量名 = 表达式;
await using 变量名 = 异步表达式;

using 声明的变量必须是一个实现了 Symbol.disposeSymbol.asyncDispose 方法的对象。
当作用域退出(无论是正常返回还是异常)时,JS 引擎会自动调用该方法释放资源。


二、基本使用示例

同步版本

class File {
  constructor(name) {
    this.name = name;
    console.log(`打开文件: ${name}`);
  }
  [Symbol.dispose]() {
    console.log(`关闭文件: ${this.name}`);
  }
}

function main() {
  using f = new File("data.txt");
  console.log("读取文件中...");
}

main();
// 输出:
// 打开文件: data.txt
// 读取文件中...
// 关闭文件: data.txt

⚡ 当 main() 执行完毕时,f 离开作用域,系统会自动调用 f[Symbol.dispose]()


异步版本

class AsyncResource {
  async [Symbol.asyncDispose]() {
    console.log("异步释放资源...");
    await new Promise(res => setTimeout(res, 1000));
    console.log("资源已释放");
  }
}

async function run() {
  await using r = new AsyncResource();
  console.log("使用异步资源中...");
}

run();

三、内部机制简析

  • using词法作用域绑定(和 let/const 类似)。

  • 离开作用域时:

    • 如果是同步资源 → 调用 [Symbol.dispose]()
    • 如果是异步资源 → 调用 [Symbol.asyncDispose]()
  • 它可以和 trycatchfinally 一起安全使用。


四、使用场景

场景 传统做法 新写法(using)
文件操作 try/finally 手动关闭 自动调用 dispose
数据库连接 手动断开 自动释放连接
临时锁 try/finally 释放锁 离开作用域自动释放

五、注意事项

  1. using 只能在模块或函数作用域中使用,不能在全局作用域直接声明。
  2. 不能与 var 共用。
  3. 不会影响 GC(垃圾回收),它只是提供结构化释放资源的机制。

六、实践demo——简化revokeObjectURL

在图片回显/文件下载等场景中,开发很容易忘记revokeObjectURL,我们可以利用using特性进行封装,消除revokeObjectURL的心智负担。

就比如上传图片后回显的操作:

console.log('🚀 using demo 启动...');

function objectURLResource(blob) {
    const url = URL.createObjectURL(blob);
    console.log('🆕 创建 URL:', url);
    return {
        url,
          [Symbol.dispose]() {
            console.log("释放资源...");
            URL.revokeObjectURL(url);
            console.log('🧹 自动释放 URL:', url);
            
        },
        // [Symbol.dispose]() {
        //     console.log("释放资源...");
        //     new Promise(resolve => setTimeout(resolve, 1000)).then(()=>{
        //         URL.revokeObjectURL(url);
        //         console.log('🧹 自动释放 URL:', url);
        //     })
            
        // },
          async [Symbol.asyncDispose]() {
            console.log("异步释放资源...");
            await new Promise(resolve => setTimeout(resolve, 3000)); // 模拟异步操作
            URL.revokeObjectURL(url);
            console.log('🧹 自动释放 URL:', url);
        },
    };
}



async function showImage(blob) {
    // await using resource = objectURLResource(blob);
    using resource = objectURLResource(blob);
    const img = document.createElement('img');
    img.src = resource.url;
    img.alt = 'demo';
    const { resolve, promise } = Promise.withResolvers()
    img.onload = () => {
        resolve()
    }
    document.body.appendChild(img);
    await promise;
    console.log('✅ 演示完毕,URL 将被自动 revoke');
}
document.querySelector('#file').addEventListener('change',async (e) => {
    const f = e.target.files[0]
   await showImage(f);
   console.log('do others...    ')

})

image.png

revokeObjectURL的注意事项

当img加载后,执行revokeObjectURL并不会影响内容显示,但如果img未加载完就执行了revokeObjectURL,则无法显示图片。因此以上demo中增加了await promise的处理,确保图片被显示出来。 image.png

那么可能会有人问,此处能否用Symbol.asyncDispose来解决该问题?答案是不能。但这是一个很好的问题,触及到了 Symbol.asyncDisposeSymbol.dispose的根本区别.

七、Symbol.asyncDispose与Symbol.dispose的区别

表面上看,以上demo中如果写成await using resource = objectURLResource(blob);,释放资源用的是Symbol.asyncDispose,如果不加await,则调用Symbol.dispose

异步释放: 异步释放

那么这两个api的使用场景分别是啥呢?我们不妨在同步释放中写下如下代码:

[Symbol.dispose]() {
    console.log("释放资源...");
    new Promise(resolve => setTimeout(resolve, 1000)).then(()=>{
        URL.revokeObjectURL(url);
        console.log('🧹 自动释放 URL:', url);
    })
},

image.png 可以看到,do others...在释放完成前就执行了。

因此可以理解:Symbol.asyncDispose是为了让后续的代码等待异步释放完成后再执行,因为有些释放场景,可能需要进行io或其他异步校验,而Symbol.dispose释放过程是同步的,后续代码执行时可以认为资源已经被释放了。如果后续代码执行时并不关心该资源是否已经释放了,那使用Symbol.dispose即可。

后端问前端:我的接口请求花了多少秒?为啥那么慢,是你慢还是我慢?

作者 江湖文人
2026年1月7日 18:10

image.png

不好意思,你说你看不懂接口 用了多少秒?我来告诉大家,因为我也要写记录📝。

我先把答案说出来,总耗时8.54秒。

  • 排队(Queued): 24.52ms
  • 连接(Connection Start): 0.86ms
  • 请求发送(Request sent): 0.23ms
  • 等待服务器响应(Waiting for server response): 8.39(这部分时间最长)
  • 内容下载(Content Download): 126.61ms

Explanation在最后一行,给出是8.54秒,也就是请求接口加起来总时间是8.54秒。


“Queued at 1.1 min” 和 “Started at 1.1 min” 是在网络性能分析工具(比如 Chrome DevTools 的性能瀑布图)中常见的描述,表示这次请求在整个页面加载过程中的出现时间点,而不是请求本身的实际时长。

具体含义:

  • Queued at 1.1 min: 表示在页面开始加载后的1.1分钟,这个请求被放入浏览器的队列中等待排队。

可能是因为:

- 浏览器对同一域名有连接数限制(例如6个并发),所以后续请求需要排队。
- 资源优先级较低,需要等其他更高优先级请求先处理。
  • Started at 1.1 min

表示在页面加载开始后的1.1分钟,这个请求开始真正被处理(例如开始建立连接)。

这两个时间相同(都是1.1分钟),说明这个请求在进入队列后几乎没有排队,很快就开始了实际请求阶段。

换句话说:

  • 1.1分钟时它被加入队列。
  • 同样在1.1分钟时它就开始了连接阶段(StalledRequest/Response等)。

所以,"Queued"和"Started at"是对整个页面加载时间线中的时间点标记,帮助你理解该请求在页面声明周期中何时发生,而不影响请求自身的8.54秒耗时。

JavaScript算法 - 冒泡排序

作者 梅川_酷子
2026年1月7日 18:03

冒泡排序(从小到大)

生成水面冒泡图片.png

/**
* 文字描述,毕竟不如口语表达,所以,这里有一些帮助阅读的方法
* 释义:
*    项:"数组的 0 项",指的是数组 index 为 0 的值
*
* tips:
* 1. 将会用到的数组,都是复杂度最高的数组,也就是原始排序为 [3,2,1] 这样从大到小的数组,有助于直观理解
* 2. 如果觉得有点乱,就关注函数返回的结果。上一步结果,就是我们下一步的根本
*/
如果数组一共有两项
  const myArr = [9, 8]
  const mySort = (arr) => {
    if (arr[0] > arr[1]) {
      ;[arr[0], arr[1]] = [arr[1], arr[0]]
    }
    return arr
  }
  const result = mySort(myArr)
  console.log(result, 'result') // [ 8, 9 ]

此时数组变为:[ 8, 9 ] 总结:以上是最简单的排序。数组只有两项,0 项和 1 项。比大小、换位,没了

如果有三个呢
  const myArr = [9, 8, 7]
  const mySort = (arr) => {
    if (arr[0] > arr[1]) {
      ;[arr[0], arr[1]] = [arr[1], arr[0]]
    }
    return arr
  }
  const result = mySort(myArr)
  console.log(result, 'result')// [ 8, 9, 7 ]

此时的数组变为:[ 8, 9, 7 ]
看着这个数组,总结:预料之中,0 项和 1 项换位。接下来呢?

排序后数组的含义可以解释为:
可以确定:数组的 1 项,是数组 0 项、1 项中的的最大值
不能确定:数组的 2 项,更大还是更小

!那么,让数组的 1、2 项比大小,就确定了数组中最大的是谁

代码延申
  const myArr = [9, 8, 7]
  const mySort = (arr) => {
    // 第一步
    if (arr[0] > arr[1]) {
      ;[arr[0], arr[1]] = [arr[1], arr[0]]
    }
    // 走到此处,arr 的值为 [ 8, 9, 7 ]

    // 第二步
    if (arr[1] > arr[2]) {
      ;[arr[1], arr[2]] = [arr[2], arr[1]]
    }
    // 走到此处,arr 的值为 [ 8, 7, 9 ]
    return arr
  }
  const result = mySort(myArr)
  console.log(result, 'result') // [ 8, 7, 9 ]

此时的数组变为:[ 8, 7, 9 ]

看着这个数组,总结:延申后,我们确定了最大值,且移动到了最右边
希望这里,你会感受到思路在变清晰

排序后的数组含义可以解释为
可以确定:0 项和 1 项,都比 2 项小。2 项,是数组的最大值
不能确定:数组的 0 项、1 项,谁大

!那么,让数组的 0、1 项比大小。(因为已经确定了 2 项最大,那么,确定了 0、1 的大小关系,就确定了全部的大小关系)

代码再延申
  const mySort = (arr) => {
    // 第一步
    if (arr[0] > arr[1]) {
      ;[arr[0], arr[1]] = [arr[1], arr[0]]
    }
    // 走到此处,arr 的值为 [ 8, 9, 7 ]

    // 第二步
    if (arr[1] > arr[2]) {
      ;[arr[1], arr[2]] = [arr[2], arr[1]]
    }
    // 走到此处,arr 的值为 [ 8, 7, 9 ]

    // 第三步(与 第一步 完全一致)
    if (arr[0] > arr[1]) {
      ;[arr[0], arr[1]] = [arr[1], arr[0]]
    }
    // 走到此处,arr 的值为[ 7, 8, 9 ]
    return arr
  }
  const result = mySort(myArr)
  console.log(result, 'result') // [ 7, 8, 9 ]

此时的数组变为:[ 7, 8, 9 ],是我们要的结果

思路总结:

我们排序的方式为:  
    0、1 对比换位,1、2 对比换位,确定 2  
    0、1 对比换位,确定 0、1  

思路扩展:

尝试想象一下,当数组的数量为四个
    0、1 对比换位,1、2 对比换位,2、3 对比换位,确定 3
然后再重复一下刚刚实现的经过
    0、1 对比换位,1、2 对比换位,确定 2
    0、1 对比换位,确定 0、1
至此,一种算法入门的排序思路已经出现,它被称为:冒泡排序 ~咕嘟咕嘟

根据思路扩展,尝试实现算法:

// 首先,写出第一轮循环对比:得到数组中最大值并移动到最右边
  const arr = [9, 8, 7, 6]
  const bubbleSort = (arr) => {
    // 先把之前的对比的判断,把索引改成动态的
    // if (arr[i] > arr[i + 1]) {
    //   ;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
    // }
    
    // 首先想到for循环。能写出来什么样呢
    // for()
    // 我们需要用一些数字。跟循环的次数有关
    // 一般而言,都会拿数组的长度来用。这里,数组的长度有什么用处
    // arr.length // 4
    // for (let i = 0; i < ???; i++) {
    //   if (arr[i] > arr[i + 1]) {
    //       ;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
    //     }
    // }
    
    // 关键是,i 要小于几呢?那就先要确定,我们要对比多少次,然后再给出限制
    // 这个对比的次数,要依据 < 思路扩展 > 中的第一轮对比!其中第一轮,经过了 3 次对比换位
    // 又因为我们是从 0 开始的。所以,要拿到的数字是0、1、2,所以要小于3
    // 数组的长度跟这个数字的关系就是 length - 1,这就是我们要小于的值
    for (let i = 0; i < arr.length - 1; i++) {
      if (arr[i] > arr[i + 1]) {
        ;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
      }
    }
    return arr
    // 如果从 1 开始,那就另一回事了
  }
  console.log(bubbleSort(arr)) // [ 8, 7, 6, 9 ]

这时候我们已经把最大的移动到了最右边,第一步完成了
然后呢?接下来,我们要对比前三个,不对比第四个了。这个怎么写啊
我们要让这个循环再来一遍。但是,循环次数要少一次。重要!!!
让循环再来一次,也就是说,让这个循环循环

  const bubbleSort = (arr) => {
    // for()
    // 外边这个循环怎么加呢,要让里面的循环,走几轮呢?跟里面的循环有什么关系呢?
    // 再一次,依据 < 思路扩展 > ,要让里面循环 3 轮,就能得到结果了
    // 里面循环对比的次数不断减 1 的,那让外面的循环不断减 1,并让这个数字同步到里面对 i 的限制
    // for(let j = ???; j >= 0; j--){
    //    for (let i = 0; i < arr.length - 1; i++) {
    //      if (arr[i] > arr[i + 1]) {
    //        ;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
    //      }
    //    }
    // }
    // 让外面的 j ,设定为里面的 i 的初始值,然后逐渐变小,最终等于 0,然后结束
    for (let j = arr.length - 1; j >= 0; j--) {
      for (let i = 0; i < j; i++) {
        if (arr[i] > arr[i + 1]) {
          ;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
        }
      }
    }
    return arr
  }
  console.log(bubbleSort(arr)) // [ 6, 7, 8, 9 ]
主线代码完成
  const bubbleSort = (arr) => {
    for (let j = arr.length - 1; j >= 0; j--) {
      for (let i = 0; i < j; i++) {
        if (arr[i] > arr[i + 1]) {
          ;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
        }
      }
    }
    return arr
  }
// 随便输入一些,看看效果
console.log(bubbleSort([543, 4, 76, 524, 24, 65, 23, 235, 3245])) // [ 4, 23, 24, 65, 76, 235, 524, 543, 3245 ]
后续优化:隔离数据源、减少非必要性能开销
  const bubbleSort = (array) => {
    const arr = [...array]
    for (let j = arr.length - 1; j >= 0; j--) {
      let swapped = false
      for (let i = 0; i < j; i++) {
        if (arr[i] > arr[i + 1]) {
          ;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
          swapped = true
        }
      }
      if (!swapped) break
    }
    return arr
  }

完成。

提升工作效率的Utils

2026年1月7日 17:46

总结一些工作中常用到的utils,希望能帮助到大家,增加摸鱼时间

getHidePhone

获取脱敏号码

/**
 * @description 隐藏手机号
 * @param {String} content 内容
 * @param {Number} hideLen 要隐藏的长度,默认为4
 * @param {String} symbol 符号,默认为*
 * @param {String} padStartOrEnd 如果平分的长度为奇数,多出的一位填充的位置,默认为end
 * @param {Boolean} removeNan 是否移除非数字,默认为true
 * @returns {String} 隐藏后的内容
 */
export const getHidePhone = (
  content: string,
  hideLen = 4,
  symbol = '*',
  padStartOrEnd: 'start' | 'end' = 'end',
  removeNan = true,
) => {
  // 如果需要先移除非数字
  if (removeNan) {
    content = content.replace(/[^\d]/g, '')
  }

  const contentLen = content.length

  // 不是字符串、空字符串、要隐藏的长度为0直接返回原始字符串
  if (getTypeOf(content) !== 'String' || !contentLen || !hideLen) return content
  // 隐藏长度大于等于内容长度,直接返回原始字符串长度的符号
  if (contentLen <= hideLen)
    return content.replace(new RegExp(`\\d{1}`, 'g'), '*')

  const remainingLen = contentLen - hideLen
  const splitLen = Math.floor(remainingLen / 2)
  let start = splitLen
  let end = splitLen
  if (remainingLen % 2 === 1) {
    if (padStartOrEnd === 'start') {
      start += 1
    } else {
      end += 1
    }
  }

  return content.replace(
    new RegExp(`^(\\d{${start}})\\d{${hideLen}}(\\d{${end}})$`),
    `$1${symbol.repeat(hideLen)}$2`,
  )
}

console.log(getHidePhone('15108324289')) // 151****4289
console.log(getHidePhone('151')) // ***
console.log(getHidePhone('1510')) // ***0
console.log(getHidePhone('')) // ''
console.log(getHidePhone('15108324289', 6)) // 15******289
console.log(getHidePhone('15108324289', 40)) // ***********
console.log(getHidePhone('15108324289', undefined, '-')) // 151----4289
console.log(getHidePhone('15108324289', undefined, undefined, 'start')) // 1510****289
console.log(getHidePhone('15108324289', undefined, undefined, 'end')) // 151****4289
console.log(getHidePhone('151-083%#2  4289', undefined, undefined)) // 151****4289
console.log(
getHidePhone('151-083%#2  4289', undefined, undefined, undefined, false),
) // 151-083%#2  4289

formateContentBySymbol

格式化内容,根据符号进行格式化,常用于千分位分割

/**
 * @description 格式化内容,根据符号进行格式化
 * @param {String} content 内容
 * @param {String} symbol 符号
 * @param {Number} gap 符号之间的间距,默认3个空格
 * @returns {String} 格式化后的内容
 */
export const formateContentBySymbol = (
  content: string,
  symbol: string,
  gap = 3,
) => {
  return content.replace(new RegExp(`(\\d{${gap}})(?=\\d)`, 'g'), `$1${symbol}`)
}

/**
 * @description 格式化内容,根据符号进行格式化
 * @param {String} content 内容
 * @param {String} symbol 符号
 * @param {Number} gap 符号之间的间距,默认3个空格
 * @returns {String} 格式化后的内容
 */
export const formateContentBySymbol2 = (
  content: string,
  symbol: string,
  gap = 3,
) => {
  return content.replace(new RegExp(`\\B(?=(\\d{${gap}})+$)`, 'g'), symbol)
}

/**
 * @description 格式化内容,根据符号进行格式化
 * @param {String} content 内容
 * @param {String} symbol 符号
 * @param {Number} gap 符号之间的间距,默认3个空格
 * @returns {String} 格式化后的内容
 */
export const formateContentBySymbol3 = (
  content: string,
  symbol: string,
  gap = 3,
) => {
  return content.replace(
    new RegExp(`(\\d)(?=(\\d{${gap}})+$)`, 'g'),
    `$1${symbol}`,
  )
}

console.log(formateContentBySymbol('235789075433254321', ',')) // 235,789,075,433,254,321
  console.log(formateContentBySymbol2('235789075433254321', ',', 7)) // 2357,8907543,3254321
  console.log(formateContentBySymbol3('235789075433254321', ',', 2)) // 23,57,89,07,54,33,25,43,21

zeroNDigitMDecimalReg

0或者n位的数字,最多m位小数正则

/**
 * @desc 0或者n位的数字,最多m位小数
 * @param n n位的数字
 * @param m 最多m位小数
 * @returns {RegExp} 返回正则表达式
 */
export const zeroNDigitMDecimalReg = (n = 4, m = 2): RegExp => {
  if (!m) {
    // 没有小数位的情况
    return new RegExp(`^(0|([1-9][0-9]{0,${n - 1}}))$`)
  }

  // 一位小数位的情况
  if (m === 1) {
    return new RegExp(`^(0(\\.[1-9])?|([1-9][0-9]{0,${n - 1}}(\\.[1-9])?))$`)
  }

  // 二位小数位的情况
  if (m === 2) {
    return new RegExp(
      `^(0(\\.(([1-9]{1,2})|0[1-9]))?|([1-9][0-9]{0,${
        n - 1
      }}(\\.(([1-9]{1,2})|0[1-9]))?))$`,
    )
  }

  // 二位以上小数位的情况
  return new RegExp(
    `^(0(\\.(([1-9]{1,${m - 1}})|([0-9]{1,${m - 1}}[1-9]?)))?|([1-9][0-9]{0,${
      n - 1
    }}(\\.(([1-9]{1,${m - 1}})|([0-9]{1,${m - 1}}[1-9]?)))?))$`,
  )
}

console.log(zeroNDigitMDecimalReg(4, 2).test('123456789.123456789')) // false
console.log(zeroNDigitMDecimalReg(4, 10).test('1232.123456789')) // true
console.log(zeroNDigitMDecimalReg(4, 2).test('1234.1')) // true
console.log(zeroNDigitMDecimalReg(8, 6).test('13323234.000001')) // true
console.log(zeroNDigitMDecimalReg(8, 6).test('13323234.0000001')) // false

nDigitReg

0或者n位的整数正则

/**
 * @desc 0或者n位的整数正则
 * @param n 最多n位的数字
 * @param with0 是否包含0
 * @returns {RegExp} 返回正则表达式
 */
export const nDigitReg = (n = 4, with0?: boolean) => {
  if (with0) {
    // 包含0的情况
    return new RegExp(`^(0|([1-9][0-9]{0,${n - 1}}))$`)
  }

  // 不包含0的情况
  return new RegExp(`^[1-9][0-9]{0,${n - 1}}$`)
}

console.log(nDigitReg(4).test('1234')) // true
console.log(nDigitReg(6).test('1234')) // true
console.log(nDigitReg(6).test('123456789')) // false
console.log(nDigitReg().test('1234.56789')) // false
console.log(nDigitReg(undefined, true).test('0')) // true
console.log(nDigitReg(undefined).test('0')) // false

onetonnine

1-9xxxx n个9

/**
 * @desc 1-9xxxx  n个9
 * @param n n位,一共多少位数字
 */
export const onetonnine = (n = 3) => {
  return new RegExp(`^[1-9][0-9]{0,${n - 1}}$`)
}

console.log(onetonnine(4).test('1234')) // true
console.log(onetonnine(4).test('123')) // true
console.log(onetonnine(4).test('12343')) // false

zerotonnine

0-9xxxx n个9

/**
 * @desc 0-9xxxx  n个9
 * @param n n位,一共多少位数字
 */
export const zerotonnine = (n = 3) => {
  return new RegExp(`^(0|([1-9][0-9]{0,${n - 1}}))$`)
}

console.log(zerotonnine(4).test('1234')) // true
console.log(zerotonnine(4).test('123')) // true
console.log(zerotonnine(4).test('12343')) // false
console.log(zerotonnine().test('0')) // true

zerotonnine2Decimal

0-9xxxx n个9, 最多两位小数

/**
 * @description 0-9xxxx n个9, 最多两位小数
 * @param {Number} n n位,一共多少位数字,默认4位数
 * @returns {RegExp} 正则
 */
export const zerotonnine2Decimal = (n = 4) => {
  return new RegExp(
    `^(0(\\.(([1-9]{1,2})|0[1-9]))?|([1-9][0-9]{0,${
      n - 1
    }}(\\.(([1-9]{1,2})|0[1-9]))?))$`,
  )
}

console.log(zerotonnine2Decimal(4).test('1234')) // true
console.log(zerotonnine2Decimal().test('1234')) // true
console.log(zerotonnine2Decimal().test('1234.00')) // false
console.log(zerotonnine2Decimal().test('1234.01')) // true
console.log(zerotonnine2Decimal().test('1234.1')) // true
console.log(zerotonnine2Decimal().test('1234.10')) // false
console.log(zerotonnine2Decimal().test('1234.001')) // false
console.log(zerotonnine2Decimal(6).test('123456789')) // false
console.log(zerotonnine2Decimal(6).test('0')) // true
console.log(zerotonnine2Decimal(6).test('0.01')) // true

onetonnine2Decimal

1-9xxxx n个9, 最多两位小数

/**
 * @description 1-9xxxx n个9, 最多两位小数
 * @param {Number} n n位,一共多少位数字,默认4位数
 * @returns {RegExp} 正则
 */
export const onetonnine2Decimal = (n = 4) => {
  return new RegExp(`^[1-9][0-9]{0,${n - 1}}(\\.(([1-9]{1,2})|0[1-9]))?$`)
}

console.log(onetonnine2Decimal(4).test('1234')) // true
console.log(onetonnine2Decimal().test('1234')) // true
console.log(onetonnine2Decimal().test('1234.00')) // false
console.log(onetonnine2Decimal().test('1234.01')) // true
console.log(onetonnine2Decimal().test('1234.1')) // true
console.log(onetonnine2Decimal().test('1234.10')) // false
console.log(onetonnine2Decimal().test('1234.001')) // false
console.log(onetonnine2Decimal(6).test('123456789')) // false
console.log(onetonnine2Decimal(6).test('0')) // false
console.log(onetonnine2Decimal(6).test('0.01')) // false

setCliboardContent

复制文本的通用函数

/**
 * @description 复制文本的通用函数
 * @param {String} content 要复制的内容
 */
export function setCliboardContent(content?: string) {
  if (!content) return

  const selection = window.getSelection()
  if (selection?.rangeCount) {
    selection?.removeAllRanges()
  }
  const el = document.createElement('textarea')
  el.value = content || ''
  el.setAttribute('readonly', '')
  el.style.position = 'absolute'
  el.style.left = '-9999px'
  document.body.appendChild(el)
  el.select()
  document.execCommand('copy')
  el.remove()
}

getCliboardValue

获取剪切板中的内容

/**
 * @description 获取剪切板中的内容
 * @returns 剪切板内容
 */
export function getCliboardValue() {
  const el = document.createElement('input')
  el.style.position = 'absolute'
  el.style.left = '-9999px'
  document.body.appendChild(el)
  el.select()
  document.execCommand('paste')
  // 获取文本输入框中的值
  const clipboardValue = el.value
  el.remove()
  return clipboardValue
}

delay

延迟执行

/**
 * @description 延迟执行
 * @param wait 延迟时间
 * @returns
 */
export function delay(wait = 1000) {
  return new Promise((resolve) => setTimeout(resolve, wait))
}

getTypeOf

获取数据类型

/**
 * @description 获取数据类型
 * @param data
 * @returns {String} 获取到的数据类型
 */
export function getTypeOf(data: any) {
  return Object.prototype.toString.call(data).slice(8, -1)
}

trimStart

去除字符串开头的空格

/**
 * @description 去除字符串开头的空格
 * @param {String} str 要处理的字符串
 * @returns {String} 去除空格后的字符串
 */
export const trimStart = (str: string) => {
  if (getTypeOf(str) !== 'String') {
    return str
  }
  return str?.replace(/^(\s+)(.*)$/g, '$2')
}

trimEnd

去除字符串结尾的空格

/**
 * @description 去除字符串结尾的空格
 * @param {String} str 要处理的字符串
 * @returns {String} 去除空格后的字符串
 */
export const trimEnd = (str: string) => {
  if (getTypeOf(str) !== 'String') {
    return str
  }
  return str?.replace(/^(.*)(\s+)$/g, '$1')
}

trimAll

去除字符串中的所有空格

/**
 * @description 去除字符串中的所有空格
 * @param {String} str 要处理的字符串
 * @returns {String} 去除空格后的字符串
 */
export const trimAll = (str: string) => {
  if (getTypeOf(str) !== 'String') {
    return str
  }
  return str?.replace(/\s+/g, '')
}

compressPic

压缩图片

/**
 * @description 压缩图片
 * @param {File} file 要处理的图片文件
 * @param {Number} quality 压缩质量
 * @returns {Promise<File | Blob>} 压缩后的图片文件
 */
export async function compressPic(
  file: File,
  quality = 0.6,
): Promise<File | Blob> {
  return new Promise((resolve) => {
    try {
      const reads = new FileReader()
      reads.readAsDataURL(file)
      reads.onload = ({ target }) => {
        // 这里quality的范围是(0-1)
        const canvas = document.createElement('canvas')
        const ctx = canvas.getContext('2d')!
        const img = new Image()
        img.src = (target as any)?.result
        img.onload = function () {
          const width = img.width
          const height = img.height
          canvas.width = width
          canvas.height = height
          ctx.drawImage(img, 0, 0, width, height)
          // 转换成base64格式 quality为图片压缩质量 0-1之间  值越小压缩的越大 图片质量越差
          canvas.toBlob(
            (blob) => {
              resolve(blob!)
            },
            file.type,
            quality,
          )
        }
      }
      reads.onerror = () => {
        resolve(file)
      }
    } catch {
      resolve(file)
    }
  })
}

randomString

生成指定长度的随机字符串

/**
 * @description 生成指定长度的随机字符串
 * @param {Number} length 输出字符串的长度
 * @param {Number} radix 字符串的基数,默认为36(包括0-36)
 * @returns 返回指定长度的随机字符串,全部为大写
 */
export const randomString = (length: number, radix = 36) => {
  // 生成一个随机字符串,基数为radix,并去除前两位"0."
  let str = Math.random().toString(radix).substring(2)
  // 如果生成的字符串长度大于等于所需长度,则截取前length个字符并转为大写
  if (str.length >= length) {
    return str.substring(0, length).toLocaleUpperCase()
  }
  // 如果字符串长度不足,递归调用自身以生成剩余长度的字符串,并拼接到原字符串上
  str += randomString(length - str.length, radix)
  // 将最终字符串转为大写并返回
  return str.toLocaleUpperCase()
}

scrollToBottom

滚动到底部

/**
 * @description 滚动到底部
 * @param {String} selector 类名
 */
export const scrollToBottom = (selector?: string) => {
  const domWrapper = selector
    ? document.querySelector(selector)
    : document.documentElement || document.body // 外层容器 出现滚动条的dom
  if (domWrapper) {
    domWrapper.scrollTo({ top: domWrapper.scrollHeight, behavior: 'smooth' })
  }
}

scrollToTop

滚动到顶部

/**
 * @description 滚动到顶部
 * @param {String} selector 类名
 */
export const scrollToTop = (selector?: string) => {
  const domWrapper = selector
    ? document.querySelector(selector)
    : document.documentElement || document.body // 外层容器 出现滚动条的dom
  if (domWrapper) {
    domWrapper.scrollTo({ top: 0, behavior: 'smooth' })
  }
}

isJSON

判断是否为JSON字符串

/**
 * @description 判断是否为JSON字符串
 * @param {String} str 字符串
 * @returns {Boolean} 是否为JSON字符串
 */
export function isJSON(str: string) {
  if (typeof str !== 'string') {
    // 1、传入值必须是 字符串
    return false
  }

  try {
    const obj = JSON.parse(str) // 2、仅仅通过 JSON.parse(str),不能完全检验一个字符串是JSON格式的字符串
    if (typeof obj === 'object' && obj) {
      //3、还必须是 object 类型
      return true
    }
    return false
  } catch {
    return false
  }
}

getRandomIntInclusive

生成指定范围内的随机整数(包含最小值和最大值)

/**
 * 生成指定范围内的随机整数(包含最小值和最大值)
 * @param min 最小值(包含)
 * @param max 最大值(包含)
 * @returns 指定范围内的随机整数
 */
export function getRandomIntInclusive(
  min: number = 1,
  max: number = 100,
): number {
  return Math.floor(Math.random() * (max - min + 1)) + min
}

记一次Vue 2主应用集成Vue 3子项目的Monorepo迁移踩坑指南

2026年1月7日 17:35

前言

最近在进行Monorepo架构调整,需要将一个现有的Vue 3(Vite)项目作为一个子应用 (apps/wj) 迁移到由Vue 2(Webpack)主导的大仓中。本以为只是简单的“文件夹移动”,结果在依赖管理、网络代理和端口映射上踩了一圈坑。

本文记录了从迁移到跑通全流程遇到的4个典型问题及解决方案。

坑点一:pnpm 严格模式下的“幽灵依赖”

💥 现象

将项目移入大仓后,执行 dev 脚本报错:

'vite' 不是内部或外部命令,也不是可运行的程序

或者启动后报错找不到 unplugin-auto-importvue-request 等插件。

🔍 原因

原项目可能使用 npm/yarn,存在依赖提升 (Hoisting) ,即 devDependencies 即使没写在 package.json 里,依靠根目录 node_modules 也能跑。 但迁移到 pnpm Monorepo 后,pnpm 的严格机制要求所有使用的包必须显式声明

✅ 解决

在根目录通过 --filter 为子应用补全依赖:

# 补全构建工具
pnpm add vite @vitejs/plugin-vue vue-tsc -D --filter wj

# 补全缺失的业务/构建插件
pnpm add unplugin-auto-import unplugin-vue-components -D --filter wj
pnpm add vue-request --filter wj

坑点二:Workspace 内部包的正确引用

💥 现象

Vite 启动报错:

Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'configs' imported from ...

子应用试图引用大仓共享的配置包(packages/configs),但找不到模块。

🔍 原因

子应用虽然物理上在 monorepo 里,但 package.json 里没有声明对内部包的依赖,导致软链接未建立。

✅ 解决

使用 --workspace 协议建立软链:

# 将内部包链接给子应用
pnpm add configs --workspace --filter wj

注意:如果共享包内部也用了某些插件(如 @vitejs/plugin-vue),共享包自己也必须安装该插件,否则会报“父级依赖缺失”。


坑点三:Node 高版本 localhost 解析陷阱 (IPv6)

💥 现象

主应用配置了代理转发到子应用,但在浏览器访问时报 HTTP 500,终端报错:

Error: connect EACCES ::1:5192

🔍 原因

  • 环境: Node.js v17+
  • 机制: 主应用代理配置写了 target: 'http://localhost:5192'。Node 默认将 localhost 解析为 IPv6 地址 ::1
  • 冲突: 子应用 (Vite) 默认只监听 IPv4 (127.0.0.1)。主应用去 IPv6 端口找人,自然连不上。

✅ 解决

方案A(推荐): 修改主应用代理配置,强制使用 IPv4 IP。

// 主应用 vite.config.ts / vue.config.js
proxy: {
  '/wj': {
    target: 'http://127.0.0.1:5192', // 👈 不要写 localhost
    changeOrigin: true
  }
}

方案B: 让子应用监听所有地址。启动命令改为 vite --host


坑点四:主应用代理“漏气” (接口返回 HTML)

💥 现象

页面加载成功,但业务接口(如 /cmisp/api/xxx)报 304200,查看 Response 内容竟然是 index.html 的代码,导致 JSON 解析失败。

🔍 原因

主应用只代理了页面路由 /wj,但子应用发出的 API 请求是 /cmisp 开头的。 主应用不认识 /cmisp,将其当成了前端路由处理,直接返回了 index.html

✅ 解决

在主应用中补全 API 的代理转发规则:

// 主应用 vite.config.ts
server: {
  proxy: {
    // 1. 子应用页面资源
    '/wj': {
      target: 'http://127.0.0.1:5192',
      changeOrigin: true
    },
    // 2. 子应用 API 请求 (新增)
    '/cmisp': {
      target: 'http://127.0.0.1:5192', // 如果是 mock 数据走这里;如果是真实后端填后端 IP
      changeOrigin: true
    }
  }
}

总结

Monorepo 迁移不仅仅是文件搬运,核心在于:

  1. 依赖边界:pnpm 下必须“谁用谁装”。
  2. 网络互通:Node 高版本下 localhost 的 IPv6 坑需要格外注意。
  3. 路由接管:主应用作为网关,必须接管子应用的所有请求(包括静态资源和 API)。

从弹窗变胖到 npm 依赖管理:一次完整的问题排查记录

作者 鹏北海
2026年1月7日 17:18

踩坑记录:从弹窗变胖到 npm 依赖管理的深度排查

2025 年 12 月 26 日

一、问题发现

接手一个老项目,第一天就遇到问题:

npm install 装不上,要么卡死要么报错。同事让我用 yarn,还给了个 yarn.lock 文件,倒是装上了。

本来以为没事了,结果开发的时候发现:弹窗怎么胖了一圈?

所有用到 el-dialog 的地方,视觉上都比设计稿大了一点。

二、问题排查

第一步:定位样式来源

打开 DevTools 看样式,发现 Element Plus 的 el-dialog 有个 padding。

我心想:这 padding 不是一直都有的吗?为啥以前正常现在不正常?

先试着把这个 padding 覆盖掉,弹窗确实恢复正常了。但这不是根本解决方案,得搞清楚为啥会这样。

第二步:对比版本差异

我去翻 Element Plus 的 changelog 和源码,发现:

从 2.5.4 版本开始,el-dialog 被强制加了 16px 的 padding。

那问题来了:我本地装的版本为啥比 package.json 里定义的高?

第三步:追溯依赖变化

package.json 里写的是:

"element-plus": "^2.3.2"

这个 ^ 表示接受 2.x.x 的任何版本。

回想一下我做了什么:因为 npm install 报错,我删了 package-lock.json 重新装。

真相大白:删掉 lock 文件后,npm 会装 2.x 下最新的版本(比如 2.13.1),然后记录到新的 package-lock.json 里。依赖就这么"偷偷"升级了。

第四步:追查 npm install 报错原因

那为啥一开始 npm install 会报错?

我去看了原来的 package-lock.json,发现里面锁定的包镜像地址有问题:

镜像源 问题描述
阿里语雀镜像 SSL 证书已过期
旧淘宝镜像 2024 年淘宝官方已迁移至新域名,旧地址已停服

上网一搜"npm install 报错",都让删 lock 文件。删了确实能装上,但版本就不对了,这就是个坑。

第五步:Node 版本冲突

还有个坑:项目要求用 Node 16.14.0,但只是口头说,没有任何强制措施。

  • 我用 16.14.0 装,报错说某个包需要 Node >= 18.12.0
  • 换到 18.12.0,又报错说另一个包需要 Node 16.x

原因是之前有人用高版本 Node 装了某些包,这些包对 Node 版本有强依赖,然后提交了 lock 文件。

三、根因分析

整个问题链条:

旧镜像地址失效
    ↓
npm install 报错
    ↓
删除 package-lock.json
    ↓
依赖版本偷偷升级(^2.3.22.13.1)
    ↓
引入 Element Plus 2.5.4+ 的破坏性变更
    ↓
弹窗多了 16px padding

本质问题

  1. 镜像地址没有统一管理,过期了没人更新
  2. Node 版本没有强制约束,各自为战
  3. 包管理器没有锁定,npm/yarn 混用
  4. Element Plus 在次版本搞破坏性变更(这个是他们的锅)

四、解决方案

4.1 统一镜像源配置

把 lock 文件里的旧镜像地址全部替换成新的淘宝镜像。

创建 .npmrc 文件锁定镜像地址(npm 和 pnpm 都读这个文件):

registry=https://registry.npmmirror.com

创建 .yarnrc 文件锁定 yarn 的镜像:

registry "https://registry.npmmirror.com"

4.2 锁定 Node 版本

加了 .nvmrc.nvmdrc 文件:

16.14.0

这样用 nvm 或 nvmd 的人切到项目目录会自动切换版本。

4.3 锁定包管理器

方法一:packageManager 字段 + Corepack

package.json 里加:

{
  "packageManager": "npm@8.5.0"
}

支持的写法:

写法 说明
"npm@8.5.0" 使用 npm 8.5.0
"yarn@1.22.19" 使用 yarn classic
"yarn@3.6.0" 使用 yarn berry (v2+)
"pnpm@8.6.0" 使用 pnpm 8.6.0

注意:只支持精确版本号,不能写 ^8.5.0

但这个字段单独写没用,得配合 Corepack 才能生效。Corepack 是 Node.js 16.9+ 内置的,但默认是禁用的:

# 启用 Corepack(需要管理员权限)
corepack enable

# Windows 用管理员终端,Mac/Linux 加 sudo
sudo corepack enable

启用后的效果:

  1. 进入项目目录时,Corepack 读取 packageManager 字段
  2. 如果本地没有对应版本,自动下载
  3. 用错包管理器直接报错
  4. 版本不对也报错

方法二:preinstall 脚本

{
  "scripts": {
    "preinstall": "npx only-allow npm"
  }
}

只允许用 npm,用 yarn 或 pnpm 装就报错。

注意:only-allow 只能限制包管理器类型,不能限制 Node 版本。

方法三:engines 字段限制 Node 版本

{
  "engines": {
    "node": ">=16.14.0 <17.0.0",
    "npm": ">=8.0.0"
  }
}

engines 支持多种写法:

写法 含义
"16.14.0" 精确版本
">=16.14.0 <17.0.0" 范围版本
"~16.14.0" 允许 16.14.x
"^16.14.0" 允许 16.x.x
"16.x || 18.x" 多版本支持

配合 .npmrc 开启严格模式才能真正生效:

engine-strict=true

启用后版本不对直接报错。

4.4 配置依赖版本前缀策略

npm 默认用 ^ 前缀,风险太大。可以在 .npmrc 里改:

配置值 效果 示例
save-prefix=^ 允许次版本升级 ^2.3.2
save-prefix=~ 仅允许修订版本升级 ~2.3.2
save-exact=true 精确版本,无前缀 2.3.2

推荐用 ~

npm/pnpm 配置(.npmrc):

save-prefix=~

yarn 配置(.yarnrc):

save-prefix "~"

为啥选 ~

方案 优点 缺点
精确版本 完全锁定,零风险 无法自动获取 bug 修复
~ 波浪号 自动获取修订版本,风险可控 极小概率遇到修订版本引入问题
^ 脱字符 自动获取新功能和修复 风险较高,如本次 Element Plus 问题

修订版本按 SemVer 规范只包含 bug 修复,向下兼容。配合 lock 文件提交,实际安装版本还是锁定的,只有删 lock 文件重装才会升级。

敏感依赖单独处理

UI 组件库这种核心依赖,建议直接在 package.json 里用精确版本:

{
  "dependencies": {
    "element-plus": "2.3.2",
    "vue": "~3.3.4"
  }
}

4.5 Element Plus 样式修复

当前方案

  1. 把 Element Plus 版本固定为精确版本(去掉 ^),防止后续静默升级
  2. 对已受影响的弹窗组件,单独进行样式覆盖:
.affected-dialog .el-dialog {
  --el-dialog-padding-primary: 0;
}

其他可选方案

  • 全局样式覆盖(影响范围大,需充分测试)
  • 回退 Element Plus 版本至 2.5.3 或更低

五、Element Plus 的问题

这事本质上是 Element Plus 的锅。

在 2.5.4 这个次版本里加了个强制 padding,这是破坏性变更。按 SemVer 语义化版本规范,破坏性变更应该放到大版本(3.x)里。

六、知识点总结

版本号前缀

写法 含义
^2.3.2 2.x.x 都行,最低 2.3.2
~2.3.2 2.3.x 都行,最低 2.3.2
2.3.2 精确版本,就要 2.3.2

SemVer 语义化版本规范

主版本.次版本.修订版本
   │      │      └── bug 修复,向下兼容
   │      └───────── 新功能,向下兼容
   └──────────────── 破坏性变更,不兼容

依赖管理配置文件

文件 作用
.npmrc npm/pnpm 配置,镜像地址、严格模式等
.yarnrc yarn 配置
.nvmrc nvm 的 Node 版本
.nvmdrc nvmd 的 Node 版本
package-lock.json npm 的依赖锁定
yarn.lock yarn 的依赖锁定
pnpm-lock.yaml pnpm 的依赖锁定

七、最佳实践

优先级 措施 说明
提交 lock 文件 防止版本漂移的核心
配置 .npmrc / .yarnrc 统一镜像源
配置 .nvmrc / .nvmdrc 本地开发版本提示
敏感依赖精确版本 UI 库等去掉 ^ 前缀
配置 save-prefix=~ 控制新依赖版本范围
配置 engines + engine-strict 强制 Node 版本检查
only-allow 脚本 限制包管理器类型
packageManager + Corepack 锁定包管理器版本

八、经验教训

  1. lock 文件必须提交,别让依赖偷偷升级
  2. 镜像地址要统一管理,用 .npmrc/.yarnrc 锁定
  3. Node 版本要强制约束,用 .nvmrc + engines + engine-strict
  4. 包管理器也要锁,packageManager + Corepack 或 only-allow
  5. 敏感依赖用精确版本,UI 库这种别用 ^
  6. 新依赖用 ~ 前缀,比 ^ 安全,比精确版本灵活
  7. 删 lock 文件要谨慎,可能引入版本漂移
  8. 遇到问题要追根溯源,不能只解决表面现象

浅谈 import.meta.env 和 process.env 的区别

2026年1月6日 22:40

这是一个前端构建环境里非常核心、也非常容易混淆的问题。下面我们从来源、使用场景、编译时机、安全性四个维度来谈谈 import.meta.envprocess.env 的区别。


一句话结论

process.env 是 Node.js 的环境变量接口 import.meta.env 是 Vite(ESM)在构建期注入的前端环境变量


一、process.env 是什么?

1️⃣ 本质

  • 来自 Node.js
  • 运行时读取 服务器 / 构建机的系统环境变量
  • 本身 浏览器里不存在
console.log(process.env.NODE_ENV);

2️⃣ 使用场景

  • Node 服务
  • 构建工具(Webpack / Vite / Rollup)
  • SSR(Node 端)

3️⃣ 前端能不能用?

👉 不能直接用

浏览器里没有 process

// 浏览器原生环境 ❌
Uncaught ReferenceError: process is not defined

4️⃣ 为什么 Webpack 项目里能用?

因为 Webpack 帮你“编译期替换”了

process.env.NODE_ENV
// ⬇️ 构建时被替换成
"production"

本质是 字符串替换,不是运行时读取。


二、import.meta.env 是什么?

1️⃣ 本质

  • Vite 提供
  • 基于 ES Module 的 import.meta
  • 构建期 + 运行期可用(但值是构建期确定的)
console.log(import.meta.env.MODE);

2️⃣ 特点

  • 浏览器里 原生支持
  • 不依赖 Node 的 process
  • 更符合现代 ESM 规范

三、两者核心区别对比(重点)

维度 process.env import.meta.env
来源 Node.js Vite
标准 Node API ESM 标准扩展
浏览器可用 ❌(需编译替换)
注入时机 构建期 构建期
是否运行时读取
推荐前端使用

⚠️ 两者都不是“前端运行时读取服务器环境变量”


四、Vite 中为什么不用 process.env

1️⃣ 因为 Vite 不再默认注入 process

// Vite 项目中 ❌
process.env.API_URL

会直接报错。

2️⃣ 官方设计选择

  • 避免 Node 全局污染
  • 更贴近浏览器真实环境
  • 更利于 Tree Shaking

五、Vite 环境变量的正确用法(非常重要)

1️⃣ 必须以 VITE_ 开头

# .env
VITE_API_URL=https://api.example.com
console.log(import.meta.env.VITE_API_URL);

❌ 否则 不会注入到前端


2️⃣ 内置变量

import.meta.env.MODE        // development / production
import.meta.env.DEV         // true / false
import.meta.env.PROD        // true / false
import.meta.env.BASE_URL

六、安全性

⚠️ 重要警告

import.meta.env 里的变量 ≠ 私密

它们会:

  • 打进 JS Bundle
  • 可在 DevTools 直接看到

❌ 不要这样做

VITE_SECRET_KEY=xxxx

✅ 正确做法

  • 前端:只放“公开配置”(API 域名、开关)
  • 私密变量:只放在 Node / 服务端

七、SSR / 全栈项目里怎么区分?

在 Vite + SSR(如 Nuxt / 自建 SSR):

Node 端

process.env.DB_PASSWORD

浏览器端

import.meta.env.VITE_API_URL

两套环境变量是刻意分开的

  1. 为什么必须分成两套?(设计原因)

1️⃣ 执行环境不同(这是根因)

位置 运行在哪 能访问什么
SSR Server Node.js process.env
Client Bundle 浏览器 import.meta.env

浏览器里 永远不可能安全地访问服务器环境变量


2️⃣ SSR ≠ 浏览器

很多人误解:

“SSR 是不是浏览器代码先在 Node 跑一遍?”

不完全对

SSR 实际是:

Node.js 先跑一份 → 生成 HTML
浏览器再跑一份 → hydrate

这两次执行:

  • 环境不同
  • 变量来源不同
  • 安全级别不同

  1. 在 Vite + SSR 中,变量的“真实流向”

1️⃣ Node 端(SSR Server)

// server.ts / entry-server.ts
const dbPassword = process.env.DB_PASSWORD;

✔️ 真实运行时读取

✔️ 不会进 bundle

✔️ 只存在于服务器内存


2️⃣ Client 端(浏览器)

// entry-client.ts / React/Vue 组件
const apiUrl = import.meta.env.VITE_API_URL;

✔️ 构建期注入

✔️ 会打进 JS

✔️ 用户可见


3️⃣ 中间那条“禁止通道”

// ❌ 绝对禁止
process.env.DB_PASSWORD → 浏览器

SSR 不会、也不允许,自动帮你“透传”环境变量


  1. SSR 中最容易踩的 3 个坑(重点)


❌ 坑 1:在“共享代码”里直接用 process.env

// utils/config.ts(被 server + client 共用)
export const API = process.env.API_URL; // ❌

问题:

  • Server OK
  • Client 直接炸(或被错误替换)

✅ 正确方式:

export const API = import.meta.env.VITE_API_URL;

或者:

export const API =typeof window === 'undefined'
    ? process.env.INTERNAL_API
    : import.meta.env.VITE_API_URL;

❌ 坑 2:误以为 SSR 可以“顺手用数据库变量”

// Vue/React 组件里
console.log(process.env.DB_PASSWORD); // ❌

哪怕你在 SSR 模式下,这段代码:

  • 最终仍会跑在浏览器
  • 会被打包
  • 是严重安全漏洞

❌ 坑 3:把“环境变量”当成“运行时配置”

// ❌ 想通过部署切换 API
import.meta.env.VITE_API_URL

🚨 这是 构建期值

build 时确定
→ CDN 缓存
→ 所有用户共享

想运行期切换?只能:

  • 接口返回配置
  • HTML 注入 window.CONFIG
  • 拉 JSON 配置文件

  1. SSR 项目里“正确的分层模型”(工程视角)

┌──────────────────────────┐
│        浏览器 Client       │
│  import.meta.env.VITE_*   │ ← 公开配置
└───────────▲──────────────┘
            │
        HTTP / HTML
            │
┌───────────┴──────────────┐
│        Node SSR Server     │
│      process.env.*        │ ← 私密配置
└───────────▲──────────────┘
            │
        内部访问
            │
┌───────────┴──────────────┐
│        DB / Redis / OSS    │
└──────────────────────────┘

这是一条 单向、安全的数据流


  1. Nuxt / 自建 SSR 的对应关系

类型 用途
runtimeConfig Server-only
runtimeConfig.public Client 可见
process.env 仅 server

👉 Nuxt 本质也是在帮你维护这条边界


八、常见误区总结

❌ 误区 1

import.meta.env 是运行时读取

,仍是构建期注入


❌ 误区 2

可以用它动态切换环境

不行,想动态只能:

  • 接口返回配置
  • 或运行时请求 JSON

❌ 误区 3

Vite 里还能继续用 process.env

❌ 除非你手动 polyfill(不推荐)


九、总结

  • 前端(Vite)只认 import.meta.env.VITE_*
  • 服务端(Node)只认 process.env
  • 永远不要把秘密放进前端 env

用心写好一个登录页:代码、体验与细节的平衡

作者 有意义
2026年1月7日 17:09

写在前面

今天,我们将使用 React + Vite + Tailwind CSS + Lucide React,快速搭建一个简洁、响应式且注重细节的登录页面,并顺手拆解几个提升用户体验的小技巧。

为什么登录页面非常重要?

别小看这个看似简单的页面——它往往是用户对产品的第一印象
登录页远不止是一个表单,更是整个产品体验的入口:设计得当,用户顺畅进入;处理草率,可能直接导致流失。

image.png


用tindwindcss完成一个登录页面。

借助 Tailwind CSS 的原子化类名体系,我们能够高效构建出美观、响应式且高度可定制的登录界面。

无需传统 CSS,仅通过组合语义清晰的工具类,即可实现精致的布局、柔和的阴影、流畅的过渡动画以及跨设备的自适应表现。

配合 React 的状态管理与 Lucide React 的简洁图标,整个登录页不仅视觉清爽,交互也细腻自然——从密码可见性切换到聚焦态反馈,每一处细节都服务于用户体验。

这不仅是“完成一个表单”,更是用代码传递信任与温度的过程。

这里用到的一些技术栈

这个小项目基于现代前端工程化理念构建,选用了以下轻量的技术组合:

React:作为核心 UI 库,利用其声明式语法和组件化思想,将登录表单拆解为可维护、可复用的逻辑单元。通过 useState 等 Hooks 管理状态,实现数据驱动的交互体验。

Tailwind CSS:采用 Utility-First(原子化)开发模式,摒弃传统 CSS 的命名负担与样式冗余。所有样式直接通过语义清晰的类名在 JSX 中组合而成,极大提升开发效率与设计一致性,同时天然支持响应式布局和主题扩展。

Lucide React:一个轻量、开源且风格统一的图标库,提供简洁优雅的 SVG 图标组件。项目中使用了 <Mail /><Lock /><Eye /> 和 <EyeOff /> 等图标,增强界面视觉引导,且无需额外配置即可与 Tailwind 样式无缝融合。

这套技术栈兼顾开发体验与运行性能,既适合快速原型验证,也具备良好的可维护性与扩展能力,是构建现代化登录界面的理想选择。

这里用到的tindwind 类名的解释:

  1. min-h-screen — 设置元素最小高度为视口高度
  2. bg-slate-50 — 设置背景色为浅 slate 灰(非常淡的灰色)
  3. flex items-center justify-center — 使用 Flex 布局,垂直和水平居中子元素
  4. p-4 — 内边距为 1rem(16px)
  5. max-w-md — 最大宽度为中等尺寸(默认 28rem / 448px)
  6. bg-white — 背景色为纯白色
  7. rounded-3xl — 圆角非常大(默认 1.5rem / 24px)
  8. shadow-xl — 添加超大阴影,增强浮层感
  9. border-slate-100 — 边框颜色为极浅 slate 灰
  10. space-y-6 — 子元素之间垂直间距为 1.5rem(24px)

实现登录页面的一些关键逻辑:

const [formData,setFormData] = useState({
    email:'',
    password:'',
    rememberMe:false
  })

这里通过 useState 定义了 formData 状态,用于统一管理用户输入的数据,包括email、password以及rememberMe

    const [showPassword,setShowPassword] = useState(false);
    const [isLoading,setLoading] = useState(false);

使用另一个状态 showPassword 来控制密码字段的可见性。当该值为 false 时,密码以密文形式显示;切换为 true 时,则以明文展示,提升用户体验,尤其在移动端输入复杂密码时非常实用

此外,还定义了 isLoading 状态,用于表示登录请求是否正在进行中。虽然当前代码中尚未接入实际的 API 调用,但这一状态为未来防止重复提交、显示加载指示器等交互提供了基础支持。

const handleChange =  (e) => {
    const {name,value,type,checked} = e.target;//input
    setFormData((prev) => ({
      ...prev,
      [name]:type === "checkbox" ? checked : value
    }));
  }

表单的输入变化由 handleChange 函数统一处理。

它通过解构事件对象的 name、value、type 和 checked 属性,智能判断当前元素类型:若是复选框(如“记住我”),则取 checked 值;否则取 value。随后,利用函数式更新方式安全地合并新值到 formData 中,确保状态更新的准确性和可维护性。

const handleSubmit = async(e) => {
      e.preventDefault();
    }

表单提交由 handleSubmit 函数接管,其首要任务是调用 e.preventDefault() 阻止浏览器默认的页面跳转或刷新行为

我们在输入框中键入内容时,handleChange 会实时捕获并更新对应状态;点击“登录”按钮时,handleSubmit 被触发,准备发起认证请求;而点击密码框右侧的眼睛图标,则会切换 showPassword 状态,动态改变密码输入框的 type 属性,实现密码的显示与隐藏。

整个流程结构清晰、状态集中、扩展性强,为构建健壮的登录界面打下了良好基础。

image.png


为什么这个登录页“可维护”?

这份代码之所以易于迭代和调试,并非偶然。所有表单数据被统一收纳在 formData 对象中结构清晰,便于追踪状态变化

输入处理逻辑被抽象为通用的 handleChange 函数,无论面对文本输入、密码框还是复选框,都能自动判断类型并更新对应字段,彻底避免了重复代码

UI 层面完全由 Tailwind 的语义化类名描述外观,而 React 状态则专注表达交互行为,两者职责分明、互不耦合。

正因如此,未来的扩展变得异常轻松:若需新增“验证码”字段,只需在状态对象中添加一个属性并绑定到新输入框;若想加入“微信登录”或“Apple 登录”等第三方选项,也只需在现有的 space-y-6 容器中插入一行即可。

这种结构天然支持灵活演进,而非牵一发而动全身。

响应式:使用场景的切换,始终优雅

界面的优雅不仅在于视觉美感,更在于它如何从容应对不同屏幕尺寸。

借助 Tailwind CSS 的响应式断点系统,我们仅用一行 p-8 md:p-10 就实现了内边距的智能适配

在手机上保持紧凑,在中等及以上屏幕则适度舒展。整个登录卡片采用居中布局,搭配柔和的 rounded-3xl 圆角与克制的 shadow-xl 阴影,在 小屏设备上不显拥挤,在 电脑大屏显示器上也依然得体。

而容器宽度 max-w-md 的设定并非随意为之——它落在人眼阅读最舒适的“黄金区间”:太宽会让视线左右扫视疲劳,太窄又显得局促不安。

这个经过验证的尺寸,是功能与美学平衡的结果。

总结

通过这个登录页的实现,我们不仅完成了一个功能完整的 UI 组件,更实践了现代前端开发的核心理念:以用户为中心,用工程化思维打造有温度的体验

借助 React 的状态管理,我们让数据流清晰可控;

利用 Tailwind CSS 的原子化样式,快速构建出响应式、一致且美观的界面;

通过 Lucide React 引入轻量图标,提升视觉引导;而像密码可见性切换、聚焦反馈、加载状态预留等细节,则体现了对用户体验的细致考量。

这不仅仅是一个登录表单——它是产品信任感的起点,是技术与设计的交汇点,也是我们作为开发者传递用心的方式。

代码可以简洁,但体验不能将就。

附录:参考文章以及源码

参考文章

关于如何在 React 项目中安装和配置 Tailwind CSS,可以参考这篇文章: Tailwind CSS 入门指南:从传统 CSS 到原子化开发的高效跃迁

我的源码:

// esm React 代表默认引入
// useState hooks 引入 部分引入
// esm cjs 优秀的地方 懒加载
import {
  useState
} from 'react';
import {
  Eye,
  EyeOff,
  Lock,
  Mail
} from 'lucide-react';
export default function App () {
  
  const [formData,setFormData] = useState({
    email:'',
    password:'',
    rememberMe:false
  })
  // 密码显示隐藏
    const [showPassword,setShowPassword] = useState(false);
    // 登录api等状态
    const [isLoading,setLoading] = useState(false);
  // 抽象的事件处理函数
  // input type="text|password|checkbox"
  // name email|password|rememberMe
  // value 数据状态
  // checked 选中状态
  const handleChange =  (e) => {
    // e.target 
    const {name,value,type,checked} = e.target;//input
    setFormData((prev) => ({
      // 传一个函数比较合适
      ...prev,
      [name]:type === "checkbox" ? checked : value
    }));
  }
   const handleSubmit = async(e) => {
      e.preventDefault();
    }
  return ( 
    <div 
      className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
        <div className="relative z-10 w-full max-w-md bg-white rounded-3xl shadow-xl shadow-slate-200/60 border-slate-100 p-8 md:p-10">
          <div className="text-center mb-10">
            <div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-600 text-white mb-4 shadow-lg shadow-indigo-200">
              <Lock size={24}/>
                
            </div>
              <h1 className="text-2xl font-bold text-slate-900">欢迎回来</h1>
              <p className="text-slate-500 mt-2">请登录你的账号</p>

          </div>
          <form onSubmit={handleSubmit} className="space-y-6">
              {/* 邮箱输入框 */}
              <div className='space-y-2'>
                <label className='text-sm font-medium text-slate-700 ml-1'>Email:</label>
                <div className='relative group'>
                  <div className="absolute inset-y-0 left-0 pl-4 
                  flex items-center pointer-events-none 
                  text-slate-400 group-focus-within:text-indigo-600 transition-colors
                  ">
                      <Mail size={18}/>

                  </div>
                  <input 
                   type="email"
                   name="email" 
                   required 
                   value={formData.email} 
                   onChange={handleChange} 
                   placeholder='name@company.com'
                   className='block w-full pl-11 pr-4 py-3 bg-slate-50 
                   border border-slate-200 rounded-xl text-slate-900
                   placeholder:text-slate-400 focus:outline-none
                   focus:ring-2 focus:ring-indigo-600/20 focus:border-indigo-600
                   transition-all'/>
                </div>
              </div>
              {/* 密码输入框 */}
           <div className="space-y-2">
            <div className="flex justify-between items-center ml-1">
              <label className="text-sm font-medium text-slate-700">密码</label>
              <a href="#" 
              className="text-sm font-medium text-indigo-600 hover:text-indigo-500 
              transition-colors">忘记密码?</a>
            </div>
            <div className="relative group">
              <div className="absolute inset-y-0 left-0 pl-4 
              flex items-center pointer-events-none
              text-slate-400 group-focus-within:text-indigo-600 transition-colors
              "
              >
                <Lock size={18} />
              </div>
              <input 
                type={showPassword ? "text" : "password"} 
                name="password"
                required
                value={formData.password}
                onChange={handleChange}
                placeholder='*******'
                className="block w-full pl-11 pr-4 py-3 bg-slate-50
                border border-slate-200 rounded-xl text-slate-900
                placeholder:text-slate-400 focus:outline-none 
                focus:ring-2 focus:ring-indigo-600/20 focus:border-indigo-600
                transition-all
                "
              />
              <button
                type="button"
                onClick={() => setShowPassword(!showPassword)}
                className="absolute inset-y-0 right-0 pr-4 flex items-center text-slate-400 hover:text-slate-600 transition-colors"
              >
                {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
              </button>
            </div>
          </div>
          </form>
        </div>
    </div>
  )
}

聊聊我对 React Hook 不一样的理解

作者 fe小陈
2026年1月7日 17:05

什么是 React Hook

React Hook 是 React 16.8 版本推出的特性,核心作用是让函数组件也能使用状态(State)、生命周期等原本只有类组件才能拥有的 React 特性。它通过一系列预定义的钩子函数(如 useState、useEffect),让开发者无需编写类组件,就能更简洁、灵活地管理组件逻辑,同时也便于逻辑的复用与拆分。

网上有大量的总结文章教会你如何使用 react hook,包括一些诸如取代 mixin 、hoc、类组件继承,所以这不是我想讲的重点。

两面性

Hook的出现不仅是React语法层面的优化,更重塑了函数组件的能力边界与代码组织方式,但也随之引入了新的认知与实践门槛。从核心能力来看,其价值主要体现在三个维度:

1. 逻辑复用的革命性突破:相比类组件时代mixins的命名冲突、HOC的嵌套地狱,Hook通过自定义Hook实现了“逻辑抽取-复用”的极简路径。开发者可将分散在不同生命周期的关联逻辑(如数据请求+加载状态+异常处理)抽离为独立Hook,在多个组件中直接复用,且不存在属性透传或嵌套冗余的问题。

2. 状态与副作用的集中管控:类组件中需分散在componentDidMount、componentDidUpdate、componentWillUnmount的副作用逻辑,在Hook中可通过useEffect统一管理,配合返回函数完成资源清理,实现“关联逻辑聚合”,大幅提升代码可读性。同时,useState、useReducer让函数组件无需依赖this即可实现灵活的状态管理,摆脱了类组件中this指向的诸多陷阱。

3. 更友好的工程化适配:Hook天然契合函数式编程理念,与TypeScript的类型推导无缝兼容,能显著降低强类型项目的开发成本。此外,React 18后续推出的useTransition、useDeferredValue等新Hook,进一步拓展了并发渲染场景下的能力,让函数组件能更好地适配现代前端复杂的性能需求。

但能力的拓展也伴随着新的痛点,这些问题往往源于对Hook设计理念的理解偏差,而非特性本身:

1. 依赖管理的“隐形门槛” :useEffect、useCallback等Hook的依赖数组是最易踩坑的环节。依赖缺失会导致副作用不触发更新,依赖冗余(如未缓存的函数、每次渲染新建的对象)则会引发频繁重渲染,更隐蔽的是“依赖闭环”导致的无限循环(如useEffect中更新state却依赖该state),排查成本极高。

2. 闭包陷阱的高频踩坑:函数组件每次渲染都会创建新的作用域,异步操作(定时器、Promise回调)极易捕获旧作用域的“过期状态”。例如依赖数组为空的useEffect中,定时器始终获取初始state值,这类问题因表象与逻辑预期背离,新手往往难以定位。

3. 副作用清理的隐蔽风险:useEffect的清理函数(返回函数)是避免内存泄漏的关键,但实际开发中常被遗漏(如window事件监听、WebSocket连接未解绑)。尤其在复杂组件中,多个副作用叠加时,清理逻辑的顺序与完整性更难把控,容易引发隐性bug。

4. 复杂场景下的性能优化难题:Hook简化了代码编写,但也容易催生“胖Hook”——一个useEffect包含多个无关副作用逻辑,导致组件耦合度升高。同时,新手常忽视useMemo、useCallback的合理使用,在大数据渲染、深层组件传递函数时,易出现不必要的重渲染,且性能瓶颈难以定位。

限制与规则

React Hook 并非可以随意使用,其设计遵循严格的规则与限制,这些规则是 React 能够稳定管理 Hook 状态关联的核心保障,违反规则可能导致组件渲染异常、状态错乱等难以排查的问题。核心规则与限制主要包括以下几点:

1. 只能在函数组件或自定义 Hook 的顶层调用:这是最核心的规则。Hook 不能嵌套在循环、条件语句(if/else)、switch 语句或嵌套函数内部调用。原因是 React 依靠 Hook 的调用顺序来建立状态与组件的关联,若调用顺序不固定(如条件判断导致某些 Hook 有时执行有时不执行),会破坏 React 对状态的追踪,导致状态错乱。例如:不能在 if (isShow) { useState(0) } 中调用 Hook。

2. 只能在 React 函数中调用 Hook:Hook 仅能用于 React 函数组件(包括箭头函数组件)和自定义 Hook 中,不能在普通的 JavaScript 函数中调用。这是因为 Hook 依赖 React 的内部机制来管理状态和副作用,普通 JS 函数不具备这样的运行环境,调用后无法正常工作。

3. 自定义 Hook 必须以 “use” 开头命名:这是 React 约定的命名规范,并非语法强制要求,但遵循该规范能让 React 识别自定义 Hook,同时让开发者快速区分普通函数与 Hook,避免误用。例如:useRequest(数据请求 Hook)、useWindowSize(监听窗口大小 Hook),若命名为 requestHook 则无法被 React 正确识别为 Hook,也不便于团队协作维护。

4. 状态更新的不可变性限制:使用 useState 或 useReducer 管理引用类型状态(对象、数组)时,必须遵循不可变性原则,不能直接修改原始状态对象(如 state.obj.name = 'new'),而应创建新的对象/数组来更新状态。因为 React 通过浅比较引用是否变化来判断是否需要重新渲染,直接修改原始状态不会改变引用,导致组件无法触发重渲染。

5. 副作用清理的必要性限制:使用 useEffect 管理副作用(如事件监听、定时器、网络连接)时,若副作用会产生内存泄漏风险(如组件卸载后仍执行回调),必须在 useEffect 的返回函数中编写清理逻辑(如移除事件监听、清除定时器、关闭连接)。这是保障组件性能和稳定性的重要限制,忽略清理可能导致内存泄漏、多次触发副作用等问题。

不一样的想法

某些规则是可以打破的

juejin.cn/post/758429…

  1. 只能在函数顶部使用 hook
  2. 条件 hook
  3. 类组件内使用 hook

类组件完全放弃了吗?代价是什么?

在新的项目中,几乎已经看不到类组件被使用(除了手搓 ErrorBoundary)。

但在享受 hook 带来函数式组件魔法的过程中,也引入了许多的问题

  1. 为了防止子组件重渲染,需要对回调函数、数据做 memo(useCallback、useMemo)
  2. 少传个 dep,导致闭包问题、子组件不更新问题
  3. 然后又引入了 React Compiler 、useEventEffect

这就有点为了填一个坑,挖了另一个坑的感觉

类组件是有可取之处的,比如

  1. 回调方法通过 this.state 是可以取到最新的状态的,因此不需要那么多 useCallback useMemo,减少了性能优化的心智负担;

  2. ref 可以直接使用组件的属性,无需像函数组件那样借助 useRef 再手动关联,操作更简洁;

  3. 生命周期逻辑时序更直观:类组件通过 componentDidMount、componentDidUpdate、componentWillUnmount 等明确的钩子划分生命周期阶段,复杂副作用(如多轮数据请求、时序依赖的资源操作)的执行时机更易把控,无需像 useEffect 那样通过依赖数组间接控制;

  4. 状态更新支持自动合并:类组件中 setState 会自动合并对象类型状态的部分属性(如 this.setState({ name: 'new' }) 不会覆盖其他未修改的状态字段),而函数组件 useState 需手动通过扩展运算符(...)实现合并,降低了状态更新的代码复杂度。

但 Hook 在逻辑注入、复用方面相比类组件有绝对的优势。

所以有没有人想过在类组件里面使用 Hook,将两者的优势结合一下? juejin.cn/post/758429…

Hook 作为状态管理的一种方式,却依赖于组件生命周期

想必 React 开发者最头疼的就是状态管理方案了,但是一旦引入了状态管理方案如 redux、zustand,你会直接失去 Hook 的能力。 juejin.cn/post/759172…

原本可以使用 ahooks 的 useRequest 发起请求,迁移到 zustand 直接就是一坨。

没有对比,就真没有伤害。

如果你用过 vue 生态中的 pinia pinia.vuejs.org/zh/cookbook… ,就会知道 pinia 是可以直接复用 vue 的 composition api 以及 VueUse 相关的能力的。

针对这个课题,我也进行了尝试。 juejin.cn/post/759120…

总结

综上,React Hook 绝非完美的“银弹”,而是一把兼具强大能力与使用门槛的“双刃剑”。它以革命性的逻辑复用方式、集中化的状态与副作用管控,以及友好的工程化适配性,重塑了React函数组件的开发模式,成为现代React项目的主流选择。但与此同时,依赖管理难题、闭包陷阱、副作用清理风险等痛点,也让开发者面临更高的认知与实践成本。

关于Hook的规则,并非绝对不可突破,在特定场景下通过合理封装实现动态Hook调用、类组件间接使用Hook等探索,为特殊需求(如旧项目迁移)提供了更多可能,但需警惕代码复杂度提升的风险。而类组件与Hook的取舍之争,本质是开发效率、可维护性与性能之间的权衡——类组件在状态获取、生命周期直观性等方面的优势仍不可忽视,完全放弃可能陷入“为填坑而挖新坑”的循环。

此外,Hook依赖组件生命周期的特性,使其在状态管理场景中存在天然局限,相比Vue Pinia对组合式API的无缝复用能力,仍有优化空间。这也提示我们,不应盲目迷信Hook的“魔法”,而应回归开发本质:既要充分发挥其逻辑复用的核心优势,也要理性看待其不足,结合项目场景(新旧项目、复杂度、团队习惯)灵活选择技术方案,甚至探索类组件与Hook的优势融合路径。最终,技术的价值在于解决问题,对Hook的理解不应局限于“规范用法”,而应基于对其底层逻辑的深刻认知,实现灵活、高效且稳定的开发实践。

React 自定义 Hooks 生存指南:7 个让你少加班的"偷懒"神器

2026年1月7日 17:00

摘要:都 2026 年了,还在写重复代码?还在 useEffect 里疯狂 copy-paste?醒醒,自定义 Hooks 才是现代 React 开发者的"摸鱼"神器。本文手把手教你封装 7 个超实用的自定义 Hooks,从此告别 996,拥抱 WLB。代码即拿即用,CV 工程师狂喜。


引言:一个关于"偷懒"的故事

场景一: 产品经理:"这个搜索框要做防抖。" 你:"好的。"(打开 Google,搜索 "react debounce") 产品经理:"那个页面也要。" 你:"好的。"(再次 copy-paste) 产品经理:"还有这 10 个页面..." 你:(开始怀疑人生)

场景二: 你:"这个表单状态管理写得真优雅。" (三个月后) 你:"这 TM 是谁写的?!" Git blame:"是你自己。" 你:(沉默)

场景三: Code Review 时—— 同事:"这段逻辑我在另外 5 个文件里见过。" 你:"那个...我准备重构的..." 同事:"你三个月前也是这么说的。" 你:(想找个地缝钻进去)

如果你也有类似经历,恭喜你,这篇文章就是为你准备的。

今天,我要分享 7 个超实用的自定义 Hooks,让你:

  • 代码复用率提升 300%
  • 每天少写 200 行重复代码
  • 准时下班不是梦

第一章:自定义 Hooks 的"道"与"术"

1.1 什么是自定义 Hook?

简单说,自定义 Hook 就是一个以 use 开头的函数,里面可以调用其他 Hooks。

// 这就是一个最简单的自定义 Hook
function useMyHook() {
  const [state, setState] = useState(null)

  useEffect(() => {
    // 做一些事情
  }, [])

  return state
}

为什么要用自定义 Hook?

  1. 复用逻辑:同样的逻辑写一次,到处用
  2. 关注点分离:组件只管渲染,逻辑交给 Hook
  3. 更好测试:Hook 可以单独测试
  4. 代码更清晰:组件代码从 500 行变成 50 行

1.2 自定义 Hook 的命名规范

// ✅ 正确:以 use 开头
useLocalStorage()
useDebounce()
useFetch()

// ❌ 错误:不以 use 开头(React 不会识别为 Hook)
getLocalStorage()
debounceValue()
fetchData()

记住:use 开头不是装逼,是 React 识别 Hook 的方式。不这么写,React 的 Hooks 规则检查会失效。


第二章:7 个让你少加班的自定义 Hooks

Hook #1:useLocalStorage —— 本地存储の优雅姿势

痛点: 每次用 localStorage 都要 JSON.parse、JSON.stringify,还要处理 SSR 报错。

解决方案:

import { useState, useEffect, useCallback } from "react"

/**
 * 将状态同步到 localStorage 的 Hook
 * @param {string} key - localStorage 的键名
 * @param {any} initialValue - 初始值
 * @returns {[any, Function, Function]} [存储的值, 设置函数, 删除函数]
 */
function useLocalStorage(key, initialValue) {
  // 获取初始值(惰性初始化)
  const [storedValue, setStoredValue] = useState(() => {
    // SSR 环境下 window 不存在
    if (typeof window === "undefined") {
      return initialValue
    }

    try {
      const item = window.localStorage.getItem(key)
      // 如果存在则解析,否则返回初始值
      return item ? JSON.parse(item) : initialValue
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error)
      return initialValue
    }
  })

  // 设置值的函数
  const setValue = useCallback(
    (value) => {
      try {
        // 支持函数式更新
        const valueToStore =
          value instanceof Function ? value(storedValue) : value
        setStoredValue(valueToStore)

        if (typeof window !== "undefined") {
          window.localStorage.setItem(key, JSON.stringify(valueToStore))
        }
      } catch (error) {
        console.warn(`Error setting localStorage key "${key}":`, error)
      }
    },
    [key, storedValue]
  )

  // 删除值的函数
  const removeValue = useCallback(() => {
    try {
      setStoredValue(initialValue)
      if (typeof window !== "undefined") {
        window.localStorage.removeItem(key)
      }
    } catch (error) {
      console.warn(`Error removing localStorage key "${key}":`, error)
    }
  }, [key, initialValue])

  return [storedValue, setValue, removeValue]
}

export default useLocalStorage

使用示例:

function App() {
  // 就像 useState 一样简单!
  const [theme, setTheme, removeTheme] = useLocalStorage("theme", "light")
  const [user, setUser] = useLocalStorage("user", null)

  return (
    <div className={`app ${theme}`}>
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        切换主题:{theme}
      </button>

      <button onClick={() => setUser({ name: "张三", age: 25 })}>登录</button>

      <button onClick={removeTheme}>重置主题</button>

      {user && <p>欢迎,{user.name}!</p>}
    </div>
  )
}

为什么这个 Hook 香?

  • 自动处理 JSON 序列化/反序列化
  • 支持 SSR(不会报 window is not defined)
  • 支持函数式更新(和 useState 一样)
  • 提供删除功能

Hook #2:useDebounce —— 防抖の终极方案

痛点: 搜索框输入时,每敲一个字就发请求,服务器直接被你打爆。

解决方案:

import { useState, useEffect } from "react"

/**
 * 防抖 Hook:延迟更新值,避免频繁触发
 * @param {any} value - 需要防抖的值
 * @param {number} delay - 延迟时间(毫秒)
 * @returns {any} 防抖后的值
 */
function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    // 设置定时器
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    // 清理函数:值变化时清除上一个定时器
    return () => {
      clearTimeout(timer)
    }
  }, [value, delay])

  return debouncedValue
}

export default useDebounce

使用示例:

function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState("")
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)

  // 防抖处理:用户停止输入 500ms 后才触发
  const debouncedSearchTerm = useDebounce(searchTerm, 500)

  useEffect(() => {
    if (debouncedSearchTerm) {
      setLoading(true)
      // 模拟 API 请求
      fetch(`/api/search?q=${debouncedSearchTerm}`)
        .then((res) => res.json())
        .then((data) => {
          setResults(data)
          setLoading(false)
        })
    } else {
      setResults([])
    }
  }, [debouncedSearchTerm]) // 只在防抖值变化时触发

  return (
    <div>
      <input
        type='text'
        placeholder='搜索...'
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />

      {loading && <p>搜索中...</p>}

      <ul>
        {results.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  )
}

进阶版:带回调的防抖

import { useCallback, useRef, useEffect } from "react"

/**
 * 防抖函数 Hook:返回一个防抖处理后的函数
 * @param {Function} callback - 需要防抖的回调函数
 * @param {number} delay - 延迟时间(毫秒)
 * @returns {Function} 防抖后的函数
 */
function useDebouncedCallback(callback, delay = 500) {
  const timeoutRef = useRef(null)
  const callbackRef = useRef(callback)

  // 保持 callback 最新
  useEffect(() => {
    callbackRef.current = callback
  }, [callback])

  // 清理定时器
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current)
      }
    }
  }, [])

  const debouncedCallback = useCallback(
    (...args) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current)
      }

      timeoutRef.current = setTimeout(() => {
        callbackRef.current(...args)
      }, delay)
    },
    [delay]
  )

  return debouncedCallback
}

// 使用示例
function SearchWithCallback() {
  const [results, setResults] = useState([])

  const handleSearch = useDebouncedCallback((term) => {
    console.log("搜索:", term)
    // 发起请求...
  }, 500)

  return (
    <input
      type='text'
      onChange={(e) => handleSearch(e.target.value)}
      placeholder='输入搜索...'
    />
  )
}

Hook #3:useFetch —— 数据请求の瑞士军刀

痛点: 每个组件都要写 loading、error、data 三件套,烦死了。

解决方案:

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

/**
 * 数据请求 Hook
 * @param {string} url - 请求地址
 * @param {object} options - fetch 选项
 * @returns {object} { data, loading, error, refetch }
 */
function useFetch(url, options = {}) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  // 用 ref 存储 options,避免无限循环
  const optionsRef = useRef(options)
  optionsRef.current = options

  const fetchData = useCallback(async () => {
    setLoading(true)
    setError(null)

    try {
      const response = await fetch(url, optionsRef.current)

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      const result = await response.json()
      setData(result)
    } catch (err) {
      setError(err.message || "请求失败")
    } finally {
      setLoading(false)
    }
  }, [url])

  useEffect(() => {
    fetchData()
  }, [fetchData])

  // 手动重新请求
  const refetch = useCallback(() => {
    fetchData()
  }, [fetchData])

  return { data, loading, error, refetch }
}

export default useFetch

使用示例:

function UserProfile({ userId }) {
  const {
    data: user,
    loading,
    error,
    refetch,
  } = useFetch(`https://jsonplaceholder.typicode.com/users/${userId}`)

  if (loading) return <div className='skeleton'>加载中...</div>
  if (error) return <div className='error'>错误:{error}</div>
  if (!user) return null

  return (
    <div className='user-profile'>
      <h2>{user.name}</h2>
      <p>📧 {user.email}</p>
      <p>📱 {user.phone}</p>
      <p>🏢 {user.company?.name}</p>

      <button onClick={refetch}>刷新数据</button>
    </div>
  )
}

进阶版:支持缓存和自动重试

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

// 简单的内存缓存
const cache = new Map()

/**
 * 增强版数据请求 Hook
 * @param {string} url - 请求地址
 * @param {object} config - 配置项
 */
function useFetchAdvanced(url, config = {}) {
  const {
    enabled = true, // 是否启用请求
    cacheTime = 5 * 60 * 1000, // 缓存时间(默认 5 分钟)
    retry = 3, // 重试次数
    retryDelay = 1000, // 重试延迟
    onSuccess, // 成功回调
    onError, // 失败回调
  } = config

  const [state, setState] = useState({
    data: null,
    loading: enabled,
    error: null,
  })

  const retryCountRef = useRef(0)

  const fetchData = useCallback(async () => {
    // 检查缓存
    const cached = cache.get(url)
    if (cached && Date.now() - cached.timestamp < cacheTime) {
      setState({ data: cached.data, loading: false, error: null })
      return
    }

    setState((prev) => ({ ...prev, loading: true, error: null }))

    try {
      const response = await fetch(url)

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`)
      }

      const data = await response.json()

      // 存入缓存
      cache.set(url, { data, timestamp: Date.now() })

      setState({ data, loading: false, error: null })
      retryCountRef.current = 0
      onSuccess?.(data)
    } catch (err) {
      // 重试逻辑
      if (retryCountRef.current < retry) {
        retryCountRef.current++
        console.log(
          `请求失败,${retryDelay}ms 后重试 (${retryCountRef.current}/${retry})`
        )
        setTimeout(fetchData, retryDelay)
        return
      }

      setState({ data: null, loading: false, error: err.message })
      onError?.(err)
    }
  }, [url, cacheTime, retry, retryDelay, onSuccess, onError])

  useEffect(() => {
    if (enabled) {
      fetchData()
    }
  }, [enabled, fetchData])

  return { ...state, refetch: fetchData }
}

Hook #4:useToggle —— 布尔值の优雅切换

痛点: setIsOpen(!isOpen) 写了 100 遍,手都酸了。

解决方案:

import { useState, useCallback } from "react"

/**
 * 布尔值切换 Hook
 * @param {boolean} initialValue - 初始值
 * @returns {[boolean, Function, Function, Function]} [值, 切换, 设为true, 设为false]
 */
function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue)

  const toggle = useCallback(() => setValue((v) => !v), [])
  const setTrue = useCallback(() => setValue(true), [])
  const setFalse = useCallback(() => setValue(false), [])

  return [value, toggle, setTrue, setFalse]
}

export default useToggle

使用示例:

function Modal() {
  const [isOpen, toggle, open, close] = useToggle(false)
  const [isDarkMode, toggleDarkMode] = useToggle(false)

  return (
    <div className={isDarkMode ? "dark" : "light"}>
      <button onClick={toggleDarkMode}>
        {isDarkMode ? "🌙" : "☀️"} 切换主题
      </button>

      <button onClick={open}>打开弹窗</button>

      {isOpen && (
        <div className='modal-overlay' onClick={close}>
          <div className='modal' onClick={(e) => e.stopPropagation()}>
            <h2>我是弹窗</h2>
            <p>点击遮罩层或按钮关闭</p>
            <button onClick={close}>关闭</button>
          </div>
        </div>
      )}
    </div>
  )
}

Hook #5:useClickOutside —— 点击外部关闭の神器

痛点: 下拉菜单、弹窗点击外部关闭,每次都要写一堆事件监听。

解决方案:

import { useEffect, useRef } from "react"

/**
 * 点击元素外部时触发回调
 * @param {Function} callback - 点击外部时的回调函数
 * @returns {React.RefObject} 需要绑定到目标元素的 ref
 */
function useClickOutside(callback) {
  const ref = useRef(null)

  useEffect(() => {
    const handleClick = (event) => {
      // 如果点击的不是 ref 元素内部,则触发回调
      if (ref.current && !ref.current.contains(event.target)) {
        callback(event)
      }
    }

    // 使用 mousedown 而不是 click,响应更快
    document.addEventListener("mousedown", handleClick)
    document.addEventListener("touchstart", handleClick)

    return () => {
      document.removeEventListener("mousedown", handleClick)
      document.removeEventListener("touchstart", handleClick)
    }
  }, [callback])

  return ref
}

export default useClickOutside

使用示例:

function Dropdown() {
  const [isOpen, setIsOpen] = useState(false)

  // 点击下拉菜单外部时关闭
  const dropdownRef = useClickOutside(() => {
    setIsOpen(false)
  })

  return (
    <div className='dropdown-container' ref={dropdownRef}>
      <button onClick={() => setIsOpen(!isOpen)}>
        选择选项 {isOpen ? "▲" : "▼"}
      </button>

      {isOpen && (
        <ul className='dropdown-menu'>
          <li onClick={() => setIsOpen(false)}>选项 1</li>
          <li onClick={() => setIsOpen(false)}>选项 2</li>
          <li onClick={() => setIsOpen(false)}>选项 3</li>
        </ul>
      )}
    </div>
  )
}

进阶:支持多个 ref

import { useEffect, useRef, useCallback } from "react"

/**
 * 支持多个元素的点击外部检测
 * @param {Function} callback - 点击外部时的回调
 * @returns {Function} 返回一个函数,调用它获取 ref
 */
function useClickOutsideMultiple(callback) {
  const refs = useRef([])

  const addRef = useCallback((element) => {
    if (element && !refs.current.includes(element)) {
      refs.current.push(element)
    }
  }, [])

  useEffect(() => {
    const handleClick = (event) => {
      const isOutside = refs.current.every(
        (ref) => ref && !ref.contains(event.target)
      )

      if (isOutside) {
        callback(event)
      }
    }

    document.addEventListener("mousedown", handleClick)
    return () => document.removeEventListener("mousedown", handleClick)
  }, [callback])

  return addRef
}

// 使用示例:弹窗 + 触发按钮都不算"外部"
function PopoverWithTrigger() {
  const [isOpen, setIsOpen] = useState(false)
  const addRef = useClickOutsideMultiple(() => setIsOpen(false))

  return (
    <>
      <button ref={addRef} onClick={() => setIsOpen(!isOpen)}>
        触发按钮
      </button>

      {isOpen && (
        <div ref={addRef} className='popover'>
          点击这里不会关闭
        </div>
      )}
    </>
  )
}

Hook #6:usePrevious —— 获取上一次的值

痛点: 想对比新旧值做一些操作,但 React 不给你上一次的值。

解决方案:

import { useRef, useEffect } from "react"

/**
 * 获取上一次渲染时的值
 * @param {any} value - 当前值
 * @returns {any} 上一次的值
 */
function usePrevious(value) {
  const ref = useRef()

  useEffect(() => {
    ref.current = value
  }, [value])

  // 返回上一次的值(在 useEffect 更新之前)
  return ref.current
}

export default usePrevious

使用示例:

function Counter() {
  const [count, setCount] = useState(0)
  const prevCount = usePrevious(count)

  return (
    <div>
      <p>当前值:{count}</p>
      <p>上一次:{prevCount ?? "无"}</p>
      <p>
        变化趋势:
        {prevCount !== undefined &&
          (count > prevCount
            ? "📈 上升"
            : count < prevCount
            ? "📉 下降"
            : "➡️ 不变")}
      </p>

      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      <button onClick={() => setCount((c) => c - 1)}>-1</button>
    </div>
  )
}

实际应用:检测 props 变化

function UserProfile({ userId }) {
  const prevUserId = usePrevious(userId)
  const [user, setUser] = useState(null)

  useEffect(() => {
    // 只有当 userId 真正变化时才重新请求
    if (userId !== prevUserId) {
      console.log(`用户 ID 从 ${prevUserId} 变为 ${userId}`)
      fetchUser(userId).then(setUser)
    }
  }, [userId, prevUserId])

  return <div>{user?.name}</div>
}

Hook #7:useMediaQuery —— 响应式の优雅方案

痛点: CSS 媒体查询很方便,但 JS 里想根据屏幕尺寸做逻辑判断就麻烦了。

解决方案:

import { useState, useEffect } from "react"

/**
 * 媒体查询 Hook
 * @param {string} query - CSS 媒体查询字符串
 * @returns {boolean} 是否匹配
 */
function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => {
    // SSR 环境下返回 false
    if (typeof window === "undefined") return false
    return window.matchMedia(query).matches
  })

  useEffect(() => {
    if (typeof window === "undefined") return

    const mediaQuery = window.matchMedia(query)

    // 初始化
    setMatches(mediaQuery.matches)

    // 监听变化
    const handler = (event) => setMatches(event.matches)

    // 现代浏览器用 addEventListener
    if (mediaQuery.addEventListener) {
      mediaQuery.addEventListener("change", handler)
      return () => mediaQuery.removeEventListener("change", handler)
    } else {
      // 兼容旧浏览器
      mediaQuery.addListener(handler)
      return () => mediaQuery.removeListener(handler)
    }
  }, [query])

  return matches
}

export default useMediaQuery

使用示例:

function ResponsiveComponent() {
  const isMobile = useMediaQuery("(max-width: 768px)")
  const isTablet = useMediaQuery("(min-width: 769px) and (max-width: 1024px)")
  const isDesktop = useMediaQuery("(min-width: 1025px)")
  const prefersDark = useMediaQuery("(prefers-color-scheme: dark)")

  return (
    <div className={prefersDark ? "dark-theme" : "light-theme"}>
      {isMobile && <MobileNav />}
      {isTablet && <TabletNav />}
      {isDesktop && <DesktopNav />}

      <main>
        <p>
          当前设备:{isMobile ? "📱 手机" : isTablet ? "📱 平板" : "💻 桌面"}
        </p>
        <p>主题偏好:{prefersDark ? "🌙 深色" : "☀️ 浅色"}</p>
      </main>
    </div>
  )
}

封装常用断点:

// hooks/useBreakpoint.js
import useMediaQuery from "./useMediaQuery"

export function useBreakpoint() {
  const breakpoints = {
    xs: useMediaQuery("(max-width: 575px)"),
    sm: useMediaQuery("(min-width: 576px) and (max-width: 767px)"),
    md: useMediaQuery("(min-width: 768px) and (max-width: 991px)"),
    lg: useMediaQuery("(min-width: 992px) and (max-width: 1199px)"),
    xl: useMediaQuery("(min-width: 1200px)"),
  }

  // 返回当前断点名称
  const current =
    Object.entries(breakpoints).find(([, matches]) => matches)?.[0] || "xs"

  return {
    ...breakpoints,
    current,
    isMobile: breakpoints.xs || breakpoints.sm,
    isTablet: breakpoints.md,
    isDesktop: breakpoints.lg || breakpoints.xl,
  }
}

// 使用
function App() {
  const { isMobile, isDesktop, current } = useBreakpoint()

  return (
    <div>
      <p>当前断点:{current}</p>
      {isMobile ? <MobileLayout /> : <DesktopLayout />}
    </div>
  )
}

第三章:Hooks 组合の艺术

3.1 组合多个 Hooks 解决复杂问题

场景: 一个带搜索、分页、缓存的列表组件

import { useState, useEffect, useMemo } from "react"

// 组合使用多个自定义 Hooks
function useSearchableList(fetchFn, options = {}) {
  const { pageSize = 10, debounceMs = 300 } = options

  // 搜索关键词
  const [searchTerm, setSearchTerm] = useState("")
  const debouncedSearch = useDebounce(searchTerm, debounceMs)

  // 分页
  const [page, setPage] = useState(1)

  // 数据请求
  const { data, loading, error, refetch } = useFetch(
    `${fetchFn}?search=${debouncedSearch}&page=${page}&pageSize=${pageSize}`
  )

  // 搜索时重置页码
  const prevSearch = usePrevious(debouncedSearch)
  useEffect(() => {
    if (prevSearch !== undefined && prevSearch !== debouncedSearch) {
      setPage(1)
    }
  }, [debouncedSearch, prevSearch])

  // 计算总页数
  const totalPages = useMemo(() => {
    return data?.total ? Math.ceil(data.total / pageSize) : 0
  }, [data?.total, pageSize])

  return {
    // 数据
    items: data?.items || [],
    total: data?.total || 0,
    loading,
    error,

    // 搜索
    searchTerm,
    setSearchTerm,

    // 分页
    page,
    setPage,
    totalPages,
    hasNextPage: page < totalPages,
    hasPrevPage: page > 1,

    // 操作
    refetch,
    nextPage: () => setPage((p) => Math.min(p + 1, totalPages)),
    prevPage: () => setPage((p) => Math.max(p - 1, 1)),
  }
}

// 使用示例
function UserList() {
  const {
    items,
    loading,
    error,
    searchTerm,
    setSearchTerm,
    page,
    totalPages,
    hasNextPage,
    hasPrevPage,
    nextPage,
    prevPage,
  } = useSearchableList("/api/users", { pageSize: 20 })

  return (
    <div className='user-list'>
      <input
        type='text'
        placeholder='搜索用户...'
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />

      {loading && <div className='loading'>加载中...</div>}
      {error && <div className='error'>{error}</div>}

      <ul>
        {items.map((user) => (
          <li key={user.id}>
            {user.name} - {user.email}
          </li>
        ))}
      </ul>

      <div className='pagination'>
        <button onClick={prevPage} disabled={!hasPrevPage}>
          上一页
        </button>
        <span>
          {page} / {totalPages}
        </span>
        <button onClick={nextPage} disabled={!hasNextPage}>
          下一页
        </button>
      </div>
    </div>
  )
}

3.2 创建 Hook 工厂

场景: 多个表单都需要类似的验证逻辑

/**
 * 表单验证 Hook 工厂
 * @param {object} validationRules - 验证规则
 * @returns {Function} 返回一个自定义 Hook
 */
function createFormValidation(validationRules) {
  return function useFormValidation(initialValues = {}) {
    const [values, setValues] = useState(initialValues)
    const [errors, setErrors] = useState({})
    const [touched, setTouched] = useState({})

    // 验证单个字段
    const validateField = (name, value) => {
      const rules = validationRules[name]
      if (!rules) return ""

      for (const rule of rules) {
        if (rule.required && !value) {
          return rule.message || "此字段必填"
        }
        if (rule.minLength && value.length < rule.minLength) {
          return rule.message || `最少 ${rule.minLength} 个字符`
        }
        if (rule.maxLength && value.length > rule.maxLength) {
          return rule.message || `最多 ${rule.maxLength} 个字符`
        }
        if (rule.pattern && !rule.pattern.test(value)) {
          return rule.message || "格式不正确"
        }
        if (rule.validate && !rule.validate(value, values)) {
          return rule.message || "验证失败"
        }
      }
      return ""
    }

    // 验证所有字段
    const validateAll = () => {
      const newErrors = {}
      let isValid = true

      Object.keys(validationRules).forEach((name) => {
        const error = validateField(name, values[name] || "")
        if (error) {
          newErrors[name] = error
          isValid = false
        }
      })

      setErrors(newErrors)
      return isValid
    }

    // 处理输入变化
    const handleChange = (name) => (e) => {
      const value = e.target ? e.target.value : e
      setValues((prev) => ({ ...prev, [name]: value }))

      // 实时验证已触碰的字段
      if (touched[name]) {
        const error = validateField(name, value)
        setErrors((prev) => ({ ...prev, [name]: error }))
      }
    }

    // 处理失焦
    const handleBlur = (name) => () => {
      setTouched((prev) => ({ ...prev, [name]: true }))
      const error = validateField(name, values[name] || "")
      setErrors((prev) => ({ ...prev, [name]: error }))
    }

    // 重置表单
    const reset = () => {
      setValues(initialValues)
      setErrors({})
      setTouched({})
    }

    return {
      values,
      errors,
      touched,
      handleChange,
      handleBlur,
      validateAll,
      reset,
      isValid: Object.keys(errors).length === 0,
      getFieldProps: (name) => ({
        value: values[name] || "",
        onChange: handleChange(name),
        onBlur: handleBlur(name),
      }),
    }
  }
}

// 创建登录表单验证 Hook
const useLoginForm = createFormValidation({
  email: [
    { required: true, message: "请输入邮箱" },
    { pattern: /^[^\s@]+@[^\s@]+.[^\s@]+$/, message: "邮箱格式不正确" },
  ],
  password: [
    { required: true, message: "请输入密码" },
    { minLength: 6, message: "密码至少 6 位" },
  ],
})

// 创建注册表单验证 Hook
const useRegisterForm = createFormValidation({
  username: [
    { required: true, message: "请输入用户名" },
    { minLength: 3, message: "用户名至少 3 个字符" },
    { maxLength: 20, message: "用户名最多 20 个字符" },
  ],
  email: [
    { required: true, message: "请输入邮箱" },
    { pattern: /^[^\s@]+@[^\s@]+.[^\s@]+$/, message: "邮箱格式不正确" },
  ],
  password: [
    { required: true, message: "请输入密码" },
    { minLength: 8, message: "密码至少 8 位" },
    {
      pattern: /(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      message: "需包含大小写字母和数字",
    },
  ],
  confirmPassword: [
    { required: true, message: "请确认密码" },
    {
      validate: (value, values) => value === values.password,
      message: "两次密码不一致",
    },
  ],
})

// 使用示例
function LoginForm() {
  const { values, errors, touched, getFieldProps, validateAll } = useLoginForm()

  const handleSubmit = (e) => {
    e.preventDefault()
    if (validateAll()) {
      console.log("提交:", values)
      // 发起登录请求...
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <div className='form-group'>
        <input type='email' placeholder='邮箱' {...getFieldProps("email")} />
        {touched.email && errors.email && (
          <span className='error'>{errors.email}</span>
        )}
      </div>

      <div className='form-group'>
        <input
          type='password'
          placeholder='密码'
          {...getFieldProps("password")}
        />
        {touched.password && errors.password && (
          <span className='error'>{errors.password}</span>
        )}
      </div>

      <button type='submit'>登录</button>
    </form>
  )
}

第四章:避坑指南

4.1 常见错误 #1:在条件语句中调用 Hook

// ❌ 错误:条件调用 Hook
function BadComponent({ shouldFetch }) {
  if (shouldFetch) {
    const data = useFetch("/api/data") // 💥 报错!
  }
  return <div>...</div>
}

// ✅ 正确:Hook 始终调用,用参数控制行为
function GoodComponent({ shouldFetch }) {
  const { data } = useFetch("/api/data", { enabled: shouldFetch })
  return <div>...</div>
}

4.2 常见错误 #2:忘记依赖项

// ❌ 错误:缺少依赖项,callback 永远是旧的
function BadHook(callback) {
  useEffect(() => {
    window.addEventListener("resize", callback)
    return () => window.removeEventListener("resize", callback)
  }, []) // callback 变了也不会更新!
}

// ✅ 正确:使用 ref 保持最新引用
function GoodHook(callback) {
  const callbackRef = useRef(callback)

  useEffect(() => {
    callbackRef.current = callback
  }, [callback])

  useEffect(() => {
    const handler = (...args) => callbackRef.current(...args)
    window.addEventListener("resize", handler)
    return () => window.removeEventListener("resize", handler)
  }, [])
}

4.3 常见错误 #3:闭包陷阱

// ❌ 错误:count 永远是 0
function BadCounter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count) // 永远打印 0
      setCount(count + 1) // 永远设置为 1
    }, 1000)
    return () => clearInterval(timer)
  }, []) // 空依赖,count 被闭包捕获

  return <div>{count}</div>
}

// ✅ 正确:使用函数式更新
function GoodCounter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const timer = setInterval(() => {
      setCount((c) => c + 1) // 函数式更新,不依赖外部 count
    }, 1000)
    return () => clearInterval(timer)
  }, [])

  return <div>{count}</div>
}

4.4 常见错误 #4:无限循环

// ❌ 错误:每次渲染都创建新对象,导致无限循环
function BadComponent() {
  const [data, setData] = useState(null)

  useEffect(() => {
    fetch("/api/data")
      .then((res) => res.json())
      .then(setData)
  }, [{ page: 1 }]) // 每次都是新对象!无限循环!

  return <div>{data}</div>
}

// ✅ 正确:使用原始值或 useMemo
function GoodComponent() {
  const [data, setData] = useState(null)
  const page = 1

  useEffect(() => {
    fetch(`/api/data?page=${page}`)
      .then((res) => res.json())
      .then(setData)
  }, [page]) // 原始值,不会无限循环

  return <div>{data}</div>
}

写在最后:Hook 的哲学

自定义 Hooks 不只是代码复用的工具,更是一种思维方式:

1. 关注点分离

  • 组件负责"长什么样"(UI)
  • Hook 负责"怎么工作"(逻辑)

2. 组合优于继承

  • 小而专注的 Hook 可以自由组合
  • 比 HOC 和 Render Props 更灵活

3. 声明式思维

  • 描述"要什么",而不是"怎么做"
  • useDebounce(value, 500) 比手写 setTimeout 清晰 100 倍

最后,送你一句话:

"好的代码不是写出来的,是删出来的。"

当你发现自己在 copy-paste 时,就是该写自定义 Hook 的时候了。


💬 互动时间:你在项目中封装过哪些好用的自定义 Hooks?评论区分享一下,让大家一起"偷懒"!

觉得这篇文章有用?点赞 + 在看 + 转发,让更多 React 开发者早点下班~


本文作者是一个靠自定义 Hooks 实现准时下班的前端开发。关注我,一起用更少的代码,写更好的应用。

AI 流式对话该怎么做?SSE、fetch、axios 一次讲清楚

作者 Sailing
2026年1月7日 16:56

在做 AI 对话产品 时,很多人都会遇到一个问题:

为什么有的实现能像 ChatGPT 一样逐字输出,而有的只能“等半天一次性返回”?

问题的核心,往往不在模型,而在 前后端的流式通信方式

本文从实战出发,系统讲清楚 SSE、fetch、axios 在 AI 流式对话中的本质区别与选型建议

先给结论(重要)

AI 流式对话的正确打开方式:

  • ✅ 首选:fetch + ReadableStream
  • ✅ 可选:SSE(EventSource)
  • ❌ 不推荐:axios

如果你现在用的是 axios,还在纠结“为什么没有逐 token 输出”,可以直接往下看结论部分。

AI 流式对话的本质需求

在传统接口中,请求和响应通常是这样的:

请求 → 等待 → 返回完整结果

但 AI 对话不是。

AI 流式对话的真实需求是:

  • 模型 逐 token 生成
  • 前端 边接收、边渲染
  • 连接可持续数十秒
  • 用户能感知“正在思考 / 正在输出”

这决定了:必须支持真正的 HTTP 流式响应

SSE、fetch、axios 的本质区别

在对比之前,先明确一个容易混淆的点:

1、SSE 是「协议能力」

SSE(Server-Sent Events) 是一种 基于 HTTP 的流式推送协议

  • Content-Type: text/event-stream
  • 服务端可以不断向客户端推送数据
  • 浏览器原生支持 EventSource

它解决的是:“服务端如何持续推送数据”

2、fetch / axios 是「请求工具」

工具 本质
fetch 浏览器原生 HTTP API
axios 对 XHR / fetch 的封装库

它们解决的是:“前端如何发请求、拿响应”

常用流式方案

SSE:最简单的流式方案

const es = new EventSource('/api/chat/stream')

es.onmessage = (e) => {
  console.log(e.data)
}

优点

  • ✅ 原生支持流式
  • ✅ 自动重连
  • ✅ 心跳、事件类型清晰
  • ✅ 非常适合 AI 单向输出

缺点(关键)

  • ❌ 只支持 GET
  • ❌ 不能自定义 Header(鉴权不友好)
  • ❌ 只能 服务端 → 客户端

适合场景:AI 回答输出推理过程 / 日志流实时通知类数据

fetch + ReadableStream(推荐)

这是目前 AI 产品中最主流、最灵活的方案

const res = await fetch('/api/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ prompt })
})

const reader = res.body.getReader()
const decoder = new TextDecoder()

while (true) {
  const { value, done } = await reader.read()
  if (done) break
  const chunk = decoder.decode(value)
  console.log(chunk)
}

为什么它是首选?

  • ✅ 支持 POST(可传 prompt、上下文)
  • ✅ 可自定义 Header(token、traceId)
  • ✅ 真正的 chunk / token 级流式
  • ✅ 与 OpenAI / Claude 接口完全一致
  • ✅ Web / Node / Edge Runtime 通用

一句话总结fetch + stream 是目前 AI 流式对话的标准

axios:为什么不适合 AI 流式?

这是很多人踩坑最多的地方。

常见误解

axios.post('/api/chat', data, {
  onDownloadProgress(e) {
    console.log(e)
  }
})

看起来像“流式”,但实际上 axios 的真实问题

  • 浏览器端基于 XHR
  • 响应会被 缓冲
  • onDownloadProgress 不是 token 级回调
  • 延迟明显、体验差

结论:axios 在浏览器端 不支持真正的流式响应

它更适合普通 REST API、表单提交、数据请求,但 不适合 AI 流式输出

总结

方案 真流式 POST Header 推荐度
SSE (EventSource) ⭐⭐⭐
fetch + stream ⭐⭐⭐⭐⭐
axios
  • SSE 是流式协议
  • fetch 是流式容器
  • axios 是传统请求工具

如果你正在做 AI 产品,通信层选错,后面再怎么优化模型和前端体验,都会事倍功半。

❌
❌